fuzzyworld

dao-game / commit

Initial port of local game to React.

417dc266c55b8886c548bb0e967047277794cdb0 · Pavel Safronov · 2022-03-23 14:50:37 -0700 · parents 7ccadf97cb49

Initial port of local game to React.
 .gitignore                                         |   2 +-
 .vscode/launch.json                                |  10 +-
 package-lock.json                                  | 670 +++++++++++++++------
 package.json                                       |  16 +-
 public/index.html                                  |   2 +-
 public/local.html                                  |   2 +-
 public/react.html                                  |  17 +
 public/remote.html                                 |   2 +-
 readme.md                                          |   2 +
 src/app.tsx                                        |  11 +
 src/game/coord.ts                                  |   8 +-
 src/game/game-board.ts                             |  49 +-
 src/game/game.ts                                   |  20 +-
 src/game/local-game.ts                             |   8 +-
 src/game/remote-game.ts                            |  22 +-
 src/react/App.css                                  |  25 +
 src/react/App.tsx                                  | 111 ++++
 src/react/Base.css                                 |  29 +
 src/react/Controls.css                             |  31 +
 src/react/Controls.tsx                             |  27 +
 src/react/Piece.css                                |  51 ++
 src/react/Piece.tsx                                |  22 +
 src/react/Status.css                               |  34 ++
 src/react/Status.tsx                               |  22 +
 src/react/Tile.css                                 |  32 +
 src/react/Tile.tsx                                 |  46 ++
 ...build-build-and-serve.ts => build-and-serve.ts} |   2 +-
 tools/build.ts                                     |   6 +
 tools/esbuild-watch.ts                             |   6 -
 tools/esbuild.ts                                   |   6 -
 tools/{esbuild-settings.ts => settings.ts}         |  14 +-
 tsconfig.json                                      |  93 +++
 32 files changed, 1141 insertions(+), 257 deletions(-)

diff --git a/.gitignore b/.gitignore
index 6760a00..f511f81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,4 @@
 /.git/*
 
 # artifacts
-/public/js
+/public/dist
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 98ef16e..1b8c55b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -27,6 +27,14 @@
             "url": "http://localhost:3000/remote.html",
             "webRoot": "${workspaceFolder}/src",
             // "preLaunchTask": "task: npm: start"
-        }
+        },
+        {
+            "type": "pwa-chrome",
+            "request": "launch",
+            "name": "Debug React Landing Page",
+            "url": "http://localhost:3000/react.html",
+            "webRoot": "${workspaceFolder}/src",
+            // "preLaunchTask": "task: npm: start"
+        },
     ]
 }
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 6db6ab6..e0567e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,12 +10,17 @@
       "license": "ISC",
       "dependencies": {
         "detect-browser": "^5.3.0",
-        "esbuild": "^0.14.25",
         "peerjs": "^1.3.2",
+        "react": "^17.0.2",
+        "react-dom": "^17.0.2",
+        "react-modal": "^3.14.4",
         "web-animations-js": "^2.3.2"
       },
       "devDependencies": {
-        "@types/node": "^17.0.21",
+        "@types/node": "^17.0.23",
+        "@types/react": "^17.0.42",
+        "@types/react-modal": "^3.13.1",
+        "esbuild": "^0.14.27",
         "rimraf": "^3.0.2",
         "ts-node": "^10.6.0",
         "typescript": "^4.6.2"
@@ -67,9 +72,41 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "17.0.21",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz",
-      "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==",
+      "version": "17.0.23",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz",
+      "integrity": "sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==",
+      "dev": true
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.4",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
+      "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==",
+      "dev": true
+    },
+    "node_modules/@types/react": {
+      "version": "17.0.42",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.42.tgz",
+      "integrity": "sha512-nuab3x3CpJ7VFeNA+3HTUuEkvClYHXqWtWd7Ud6AZYW7Z3NH9WKtgU+tFB0ZLcHq+niB/HnzLcaZPqMJ95+k5Q==",
+      "dev": true,
+      "dependencies": {
+        "@types/prop-types": "*",
+        "@types/scheduler": "*",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@types/react-modal": {
+      "version": "3.13.1",
+      "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz",
+      "integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/scheduler": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
+      "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
       "dev": true
     },
     "node_modules/acorn": {
@@ -127,6 +164,12 @@
       "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
       "dev": true
     },
+    "node_modules/csstype": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
+      "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+      "dev": true
+    },
     "node_modules/detect-browser": {
       "version": "5.3.0",
       "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz",
@@ -142,9 +185,10 @@
       }
     },
     "node_modules/esbuild": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.25.tgz",
-      "integrity": "sha512-4JHEIOMNFvK09ziiL+iVmldIhLbn49V4NAVo888tcGFKedEZY/Y8YapfStJ6zSE23tzYPKxqKwQBnQoIO0BI/Q==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.27.tgz",
+      "integrity": "sha512-MZQt5SywZS3hA9fXnMhR22dv0oPGh6QtjJRIYbgL1AeqAoQZE+Qn5ppGYQAoHv/vq827flj4tIJ79Mrdiwk46Q==",
+      "dev": true,
       "hasInstallScript": true,
       "bin": {
         "esbuild": "bin/esbuild"
@@ -153,35 +197,36 @@
         "node": ">=12"
       },
       "optionalDependencies": {
-        "esbuild-android-64": "0.14.25",
-        "esbuild-android-arm64": "0.14.25",
-        "esbuild-darwin-64": "0.14.25",
-        "esbuild-darwin-arm64": "0.14.25",
-        "esbuild-freebsd-64": "0.14.25",
-        "esbuild-freebsd-arm64": "0.14.25",
-        "esbuild-linux-32": "0.14.25",
-        "esbuild-linux-64": "0.14.25",
-        "esbuild-linux-arm": "0.14.25",
-        "esbuild-linux-arm64": "0.14.25",
-        "esbuild-linux-mips64le": "0.14.25",
-        "esbuild-linux-ppc64le": "0.14.25",
-        "esbuild-linux-riscv64": "0.14.25",
-        "esbuild-linux-s390x": "0.14.25",
-        "esbuild-netbsd-64": "0.14.25",
-        "esbuild-openbsd-64": "0.14.25",
-        "esbuild-sunos-64": "0.14.25",
-        "esbuild-windows-32": "0.14.25",
-        "esbuild-windows-64": "0.14.25",
-        "esbuild-windows-arm64": "0.14.25"
+        "esbuild-android-64": "0.14.27",
+        "esbuild-android-arm64": "0.14.27",
+        "esbuild-darwin-64": "0.14.27",
+        "esbuild-darwin-arm64": "0.14.27",
+        "esbuild-freebsd-64": "0.14.27",
+        "esbuild-freebsd-arm64": "0.14.27",
+        "esbuild-linux-32": "0.14.27",
+        "esbuild-linux-64": "0.14.27",
+        "esbuild-linux-arm": "0.14.27",
+        "esbuild-linux-arm64": "0.14.27",
+        "esbuild-linux-mips64le": "0.14.27",
+        "esbuild-linux-ppc64le": "0.14.27",
+        "esbuild-linux-riscv64": "0.14.27",
+        "esbuild-linux-s390x": "0.14.27",
+        "esbuild-netbsd-64": "0.14.27",
+        "esbuild-openbsd-64": "0.14.27",
+        "esbuild-sunos-64": "0.14.27",
+        "esbuild-windows-32": "0.14.27",
+        "esbuild-windows-64": "0.14.27",
+        "esbuild-windows-arm64": "0.14.27"
       }
     },
     "node_modules/esbuild-android-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.25.tgz",
-      "integrity": "sha512-L5vCUk7TzFbBnoESNoXjU3x9+/+7TDIE/1mTfy/erAfvZAqC+S3sp/Qa9wkypFMcFvN9FzvESkTlpeQDolREtQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.27.tgz",
+      "integrity": "sha512-LuEd4uPuj/16Y8j6kqy3Z2E9vNY9logfq8Tq+oTE2PZVuNs3M1kj5Qd4O95ee66yDGb3isaOCV7sOLDwtMfGaQ==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "android"
@@ -191,12 +236,13 @@
       }
     },
     "node_modules/esbuild-android-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.25.tgz",
-      "integrity": "sha512-4jv5xPjM/qNm27T5j3ZEck0PvjgQtoMHnz4FzwF5zNP56PvY2CT0WStcAIl6jNlsuDdN63rk2HRBIsO6xFbcFw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.27.tgz",
+      "integrity": "sha512-E8Ktwwa6vX8q7QeJmg8yepBYXaee50OdQS3BFtEHKrzbV45H4foMOeEE7uqdjGQZFBap5VAqo7pvjlyA92wznQ==",
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "android"
@@ -206,12 +252,13 @@
       }
     },
     "node_modules/esbuild-darwin-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.25.tgz",
-      "integrity": "sha512-TGp8tuudIxOyWd1+8aYPxQmC1ZQyvij/AfNBa35RubixD0zJ1vkKHVAzo0Zao1zcG6pNqiSyzfPto8vmg0s7oA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.27.tgz",
+      "integrity": "sha512-czw/kXl/1ZdenPWfw9jDc5iuIYxqUxgQ/Q+hRd4/3udyGGVI31r29LCViN2bAJgGvQkqyLGVcG03PJPEXQ5i2g==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "darwin"
@@ -221,12 +268,13 @@
       }
     },
     "node_modules/esbuild-darwin-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.25.tgz",
-      "integrity": "sha512-oTcDgdm0MDVEmw2DWu8BV68pYuImpFgvWREPErBZmNA4MYKGuBRaCiJqq6jZmBR1x+3y1DWCjez+5uLtuAm6mw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.27.tgz",
+      "integrity": "sha512-BEsv2U2U4o672oV8+xpXNxN9bgqRCtddQC6WBh4YhXKDcSZcdNh7+6nS+DM2vu7qWIWNA4JbRG24LUUYXysimQ==",
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "darwin"
@@ -236,12 +284,13 @@
       }
     },
     "node_modules/esbuild-freebsd-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.25.tgz",
-      "integrity": "sha512-ueAqbnMZ8arnuLH8tHwTCQYeptnHOUV7vA6px6j4zjjQwDx7TdP7kACPf3TLZLdJQ3CAD1XCvQ2sPhX+8tacvQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.27.tgz",
+      "integrity": "sha512-7FeiFPGBo+ga+kOkDxtPmdPZdayrSzsV9pmfHxcyLKxu+3oTcajeZlOO1y9HW+t5aFZPiv7czOHM4KNd0tNwCA==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "freebsd"
@@ -251,12 +300,13 @@
       }
     },
     "node_modules/esbuild-freebsd-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.25.tgz",
-      "integrity": "sha512-+ZVWud2HKh+Ob6k/qiJWjBtUg4KmJGGmbvEXXW1SNKS7hW7HU+Zq2ZCcE1akFxOPkVB+EhOty/sSek30tkCYug==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.27.tgz",
+      "integrity": "sha512-8CK3++foRZJluOWXpllG5zwAVlxtv36NpHfsbWS7TYlD8S+QruXltKlXToc/5ZNzBK++l6rvRKELu/puCLc7jA==",
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "freebsd"
@@ -266,12 +316,13 @@
       }
     },
     "node_modules/esbuild-linux-32": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.25.tgz",
-      "integrity": "sha512-3OP/lwV3kCzEz45tobH9nj+uE4ubhGsfx+tn0L26WAGtUbmmcRpqy7XRG/qK7h1mClZ+eguIANcQntYMdYklfw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.27.tgz",
+      "integrity": "sha512-qhNYIcT+EsYSBClZ5QhLzFzV5iVsP1YsITqblSaztr3+ZJUI+GoK8aXHyzKd7/CKKuK93cxEMJPpfi1dfsOfdw==",
       "cpu": [
         "ia32"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -281,12 +332,13 @@
       }
     },
     "node_modules/esbuild-linux-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.25.tgz",
-      "integrity": "sha512-+aKHdHZmX9qwVlQmu5xYXh7GsBFf4TWrePgeJTalhXHOG7NNuUwoHmketGiZEoNsWyyqwH9rE5BC+iwcLY30Ug==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.27.tgz",
+      "integrity": "sha512-ESjck9+EsHoTaKWlFKJpPZRN26uiav5gkI16RuI8WBxUdLrrAlYuYSndxxKgEn1csd968BX/8yQZATYf/9+/qg==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -296,12 +348,13 @@
       }
     },
     "node_modules/esbuild-linux-arm": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.25.tgz",
-      "integrity": "sha512-aTLcE2VBoLydL943REcAcgnDi3bHtmULSXWLbjtBdtykRatJVSxKMjK9YlBXUZC4/YcNQfH7AxwVeQr9fNxPhw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.27.tgz",
+      "integrity": "sha512-JnnmgUBdqLQO9hoNZQqNHFWlNpSX82vzB3rYuCJMhtkuaWQEmQz6Lec1UIxJdC38ifEghNTBsF9bbe8dFilnCw==",
       "cpu": [
         "arm"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -311,12 +364,13 @@
       }
     },
     "node_modules/esbuild-linux-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.25.tgz",
-      "integrity": "sha512-UxfenPx/wSZx55gScCImPtXekvZQLI2GW3qe5dtlmU7luiqhp5GWPzGeQEbD3yN3xg/pHc671m5bma5Ns7lBHw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.27.tgz",
+      "integrity": "sha512-no6Mi17eV2tHlJnqBHRLekpZ2/VYx+NfGxKcBE/2xOMYwctsanCaXxw4zapvNrGE9X38vefVXLz6YCF8b1EHiQ==",
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -326,12 +380,13 @@
       }
     },
     "node_modules/esbuild-linux-mips64le": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.25.tgz",
-      "integrity": "sha512-wLWYyqVfYx9Ur6eU5RT92yJVsaBGi5RdkoWqRHOqcJ38Kn60QMlcghsKeWfe9jcYut8LangYZ98xO1LxIoSXrQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.27.tgz",
+      "integrity": "sha512-NolWP2uOvIJpbwpsDbwfeExZOY1bZNlWE/kVfkzLMsSgqeVcl5YMen/cedRe9mKnpfLli+i0uSp7N+fkKNU27A==",
       "cpu": [
         "mips64el"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -341,12 +396,13 @@
       }
     },
     "node_modules/esbuild-linux-ppc64le": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.25.tgz",
-      "integrity": "sha512-0dR6Csl6Zas3g4p9ULckEl8Mo8IInJh33VCJ3eaV1hj9+MHGdmDOakYMN8MZP9/5nl+NU/0ygpd14cWgy8uqRw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.27.tgz",
+      "integrity": "sha512-/7dTjDvXMdRKmsSxKXeWyonuGgblnYDn0MI1xDC7J1VQXny8k1qgNp6VmrlsawwnsymSUUiThhkJsI+rx0taNA==",
       "cpu": [
         "ppc64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -356,12 +412,13 @@
       }
     },
     "node_modules/esbuild-linux-riscv64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.25.tgz",
-      "integrity": "sha512-J4d20HDmTrgvhR0bdkDhvvJGaikH3LzXQnNaseo8rcw9Yqby9A90gKUmWpfwqLVNRILvNnAmKLfBjCKU9ajg8w==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.27.tgz",
+      "integrity": "sha512-D+aFiUzOJG13RhrSmZgrcFaF4UUHpqj7XSKrIiCXIj1dkIkFqdrmqMSOtSs78dOtObWiOrFCDDzB24UyeEiNGg==",
       "cpu": [
         "riscv64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -371,12 +428,13 @@
       }
     },
     "node_modules/esbuild-linux-s390x": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.25.tgz",
-      "integrity": "sha512-YI2d5V6nTE73ZnhEKQD7MtsPs1EtUZJ3obS21oxQxGbbRw1G+PtJKjNyur+3t6nzHP9oTg6GHQ3S3hOLLmbDIQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.27.tgz",
+      "integrity": "sha512-CD/D4tj0U4UQjELkdNlZhQ8nDHU5rBn6NGp47Hiz0Y7/akAY5i0oGadhEIg0WCY/HYVXFb3CsSPPwaKcTOW3bg==",
       "cpu": [
         "s390x"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -386,12 +444,13 @@
       }
     },
     "node_modules/esbuild-netbsd-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.25.tgz",
-      "integrity": "sha512-TKIVgNWLUOkr+Exrye70XTEE1lJjdQXdM4tAXRzfHE9iBA7LXWcNtVIuSnphTqpanPzTDFarF0yqq4kpbC6miA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.27.tgz",
+      "integrity": "sha512-h3mAld69SrO1VoaMpYl3a5FNdGRE/Nqc+E8VtHOag4tyBwhCQXxtvDDOAKOUQexBGca0IuR6UayQ4ntSX5ij1Q==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "netbsd"
@@ -401,12 +460,13 @@
       }
     },
     "node_modules/esbuild-openbsd-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.25.tgz",
-      "integrity": "sha512-QgFJ37A15D7NIXBTYEqz29+uw3nNBOIyog+3kFidANn6kjw0GHZ0lEYQn+cwjyzu94WobR+fes7cTl/ZYlHb1A==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.27.tgz",
+      "integrity": "sha512-xwSje6qIZaDHXWoPpIgvL+7fC6WeubHHv18tusLYMwL+Z6bEa4Pbfs5IWDtQdHkArtfxEkIZz77944z8MgDxGw==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "openbsd"
@@ -416,12 +476,13 @@
       }
     },
     "node_modules/esbuild-sunos-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.25.tgz",
-      "integrity": "sha512-rmWfjUItYIVlqr5EnTH1+GCxXiBOC42WBZ3w++qh7n2cS9Xo0lO5pGSG2N+huOU2fX5L+6YUuJ78/vOYvefeFw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.27.tgz",
+      "integrity": "sha512-/nBVpWIDjYiyMhuqIqbXXsxBc58cBVH9uztAOIfWShStxq9BNBik92oPQPJ57nzWXRNKQUEFWr4Q98utDWz7jg==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "sunos"
@@ -431,12 +492,13 @@
       }
     },
     "node_modules/esbuild-windows-32": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.25.tgz",
-      "integrity": "sha512-HGAxVUofl3iUIz9W10Y9XKtD0bNsK9fBXv1D55N/ljNvkrAYcGB8YCm0v7DjlwtyS6ws3dkdQyXadbxkbzaKOA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.27.tgz",
+      "integrity": "sha512-Q9/zEjhZJ4trtWhFWIZvS/7RUzzi8rvkoaS9oiizkHTTKd8UxFwn/Mm2OywsAfYymgUYm8+y2b+BKTNEFxUekw==",
       "cpu": [
         "ia32"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "win32"
@@ -446,12 +508,13 @@
       }
     },
     "node_modules/esbuild-windows-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.25.tgz",
-      "integrity": "sha512-TirEohRkfWU9hXLgoDxzhMQD1g8I2mOqvdQF2RS9E/wbkORTAqJHyh7wqGRCQAwNzdNXdg3JAyhQ9/177AadWA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.27.tgz",
+      "integrity": "sha512-b3y3vTSl5aEhWHK66ngtiS/c6byLf6y/ZBvODH1YkBM+MGtVL6jN38FdHUsZasCz9gFwYs/lJMVY9u7GL6wfYg==",
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "win32"
@@ -461,12 +524,13 @@
       }
     },
     "node_modules/esbuild-windows-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.25.tgz",
-      "integrity": "sha512-4ype9ERiI45rSh+R8qUoBtaj6kJvUOI7oVLhKqPEpcF4Pa5PpT3hm/mXAyotJHREkHpM87PAJcA442mLnbtlNA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.27.tgz",
+      "integrity": "sha512-I/reTxr6TFMcR5qbIkwRGvldMIaiBu2+MP0LlD7sOlNXrfqIl9uNjsuxFPGEG4IRomjfQ5q8WT+xlF/ySVkqKg==",
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "optional": true,
       "os": [
         "win32"
@@ -480,6 +544,11 @@
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
       "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
     },
+    "node_modules/exenv": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
+      "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50="
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -522,6 +591,22 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "dev": true
     },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
     "node_modules/make-error": {
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -540,6 +625,14 @@
         "node": "*"
       }
     },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -579,6 +672,69 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
       "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="
     },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/react": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
+      "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
+      "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "scheduler": "^0.20.2"
+      },
+      "peerDependencies": {
+        "react": "17.0.2"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+    },
+    "node_modules/react-lifecycles-compat": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
+    },
+    "node_modules/react-modal": {
+      "version": "3.14.4",
+      "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.14.4.tgz",
+      "integrity": "sha512-8surmulejafYCH9wfUmFyj4UfbSJwjcgbS9gf3oOItu4Hwd6ivJyVBETI0yHRhpJKCLZMUtnhzk76wXTsNL6Qg==",
+      "dependencies": {
+        "exenv": "^1.2.0",
+        "prop-types": "^15.7.2",
+        "react-lifecycles-compat": "^3.0.0",
+        "warning": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "peerDependencies": {
+        "react": "^0.14.0 || ^15.0.0 || ^16 || ^17",
+        "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17"
+      }
+    },
     "node_modules/rimraf": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -606,6 +762,15 @@
         "npm": ">=3.10.0"
       }
     },
+    "node_modules/scheduler": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
+      "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      }
+    },
     "node_modules/sdp": {
       "version": "2.12.0",
       "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
@@ -672,6 +837,14 @@
       "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==",
       "dev": true
     },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "node_modules/web-animations-js": {
       "version": "2.3.2",
       "resolved": "https://registry.npmjs.org/web-animations-js/-/web-animations-js-2.3.2.tgz",
@@ -747,9 +920,41 @@
       "dev": true
     },
     "@types/node": {
-      "version": "17.0.21",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz",
-      "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==",
+      "version": "17.0.23",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz",
+      "integrity": "sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==",
+      "dev": true
+    },
+    "@types/prop-types": {
+      "version": "15.7.4",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
+      "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==",
+      "dev": true
+    },
+    "@types/react": {
+      "version": "17.0.42",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.42.tgz",
+      "integrity": "sha512-nuab3x3CpJ7VFeNA+3HTUuEkvClYHXqWtWd7Ud6AZYW7Z3NH9WKtgU+tFB0ZLcHq+niB/HnzLcaZPqMJ95+k5Q==",
+      "dev": true,
+      "requires": {
+        "@types/prop-types": "*",
+        "@types/scheduler": "*",
+        "csstype": "^3.0.2"
+      }
+    },
+    "@types/react-modal": {
+      "version": "3.13.1",
+      "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz",
+      "integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
+    "@types/scheduler": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
+      "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
       "dev": true
     },
     "acorn": {
@@ -798,6 +1003,12 @@
       "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
       "dev": true
     },
+    "csstype": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
+      "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+      "dev": true
+    },
     "detect-browser": {
       "version": "5.3.0",
       "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz",
@@ -810,150 +1021,171 @@
       "dev": true
     },
     "esbuild": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.25.tgz",
-      "integrity": "sha512-4JHEIOMNFvK09ziiL+iVmldIhLbn49V4NAVo888tcGFKedEZY/Y8YapfStJ6zSE23tzYPKxqKwQBnQoIO0BI/Q==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.27.tgz",
+      "integrity": "sha512-MZQt5SywZS3hA9fXnMhR22dv0oPGh6QtjJRIYbgL1AeqAoQZE+Qn5ppGYQAoHv/vq827flj4tIJ79Mrdiwk46Q==",
+      "dev": true,
       "requires": {
-        "esbuild-android-64": "0.14.25",
-        "esbuild-android-arm64": "0.14.25",
-        "esbuild-darwin-64": "0.14.25",
-        "esbuild-darwin-arm64": "0.14.25",
-        "esbuild-freebsd-64": "0.14.25",
-        "esbuild-freebsd-arm64": "0.14.25",
-        "esbuild-linux-32": "0.14.25",
-        "esbuild-linux-64": "0.14.25",
-        "esbuild-linux-arm": "0.14.25",
-        "esbuild-linux-arm64": "0.14.25",
-        "esbuild-linux-mips64le": "0.14.25",
-        "esbuild-linux-ppc64le": "0.14.25",
-        "esbuild-linux-riscv64": "0.14.25",
-        "esbuild-linux-s390x": "0.14.25",
-        "esbuild-netbsd-64": "0.14.25",
-        "esbuild-openbsd-64": "0.14.25",
-        "esbuild-sunos-64": "0.14.25",
-        "esbuild-windows-32": "0.14.25",
-        "esbuild-windows-64": "0.14.25",
-        "esbuild-windows-arm64": "0.14.25"
+        "esbuild-android-64": "0.14.27",
+        "esbuild-android-arm64": "0.14.27",
+        "esbuild-darwin-64": "0.14.27",
+        "esbuild-darwin-arm64": "0.14.27",
+        "esbuild-freebsd-64": "0.14.27",
+        "esbuild-freebsd-arm64": "0.14.27",
+        "esbuild-linux-32": "0.14.27",
+        "esbuild-linux-64": "0.14.27",
+        "esbuild-linux-arm": "0.14.27",
+        "esbuild-linux-arm64": "0.14.27",
+        "esbuild-linux-mips64le": "0.14.27",
+        "esbuild-linux-ppc64le": "0.14.27",
+        "esbuild-linux-riscv64": "0.14.27",
+        "esbuild-linux-s390x": "0.14.27",
+        "esbuild-netbsd-64": "0.14.27",
+        "esbuild-openbsd-64": "0.14.27",
+        "esbuild-sunos-64": "0.14.27",
+        "esbuild-windows-32": "0.14.27",
+        "esbuild-windows-64": "0.14.27",
+        "esbuild-windows-arm64": "0.14.27"
       }
     },
     "esbuild-android-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.25.tgz",
-      "integrity": "sha512-L5vCUk7TzFbBnoESNoXjU3x9+/+7TDIE/1mTfy/erAfvZAqC+S3sp/Qa9wkypFMcFvN9FzvESkTlpeQDolREtQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.27.tgz",
+      "integrity": "sha512-LuEd4uPuj/16Y8j6kqy3Z2E9vNY9logfq8Tq+oTE2PZVuNs3M1kj5Qd4O95ee66yDGb3isaOCV7sOLDwtMfGaQ==",
+      "dev": true,
       "optional": true
     },
     "esbuild-android-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.25.tgz",
-      "integrity": "sha512-4jv5xPjM/qNm27T5j3ZEck0PvjgQtoMHnz4FzwF5zNP56PvY2CT0WStcAIl6jNlsuDdN63rk2HRBIsO6xFbcFw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.27.tgz",
+      "integrity": "sha512-E8Ktwwa6vX8q7QeJmg8yepBYXaee50OdQS3BFtEHKrzbV45H4foMOeEE7uqdjGQZFBap5VAqo7pvjlyA92wznQ==",
+      "dev": true,
       "optional": true
     },
     "esbuild-darwin-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.25.tgz",
-      "integrity": "sha512-TGp8tuudIxOyWd1+8aYPxQmC1ZQyvij/AfNBa35RubixD0zJ1vkKHVAzo0Zao1zcG6pNqiSyzfPto8vmg0s7oA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.27.tgz",
+      "integrity": "sha512-czw/kXl/1ZdenPWfw9jDc5iuIYxqUxgQ/Q+hRd4/3udyGGVI31r29LCViN2bAJgGvQkqyLGVcG03PJPEXQ5i2g==",
+      "dev": true,
       "optional": true
     },
     "esbuild-darwin-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.25.tgz",
-      "integrity": "sha512-oTcDgdm0MDVEmw2DWu8BV68pYuImpFgvWREPErBZmNA4MYKGuBRaCiJqq6jZmBR1x+3y1DWCjez+5uLtuAm6mw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.27.tgz",
+      "integrity": "sha512-BEsv2U2U4o672oV8+xpXNxN9bgqRCtddQC6WBh4YhXKDcSZcdNh7+6nS+DM2vu7qWIWNA4JbRG24LUUYXysimQ==",
+      "dev": true,
       "optional": true
     },
     "esbuild-freebsd-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.25.tgz",
-      "integrity": "sha512-ueAqbnMZ8arnuLH8tHwTCQYeptnHOUV7vA6px6j4zjjQwDx7TdP7kACPf3TLZLdJQ3CAD1XCvQ2sPhX+8tacvQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.27.tgz",
+      "integrity": "sha512-7FeiFPGBo+ga+kOkDxtPmdPZdayrSzsV9pmfHxcyLKxu+3oTcajeZlOO1y9HW+t5aFZPiv7czOHM4KNd0tNwCA==",
+      "dev": true,
       "optional": true
     },
     "esbuild-freebsd-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.25.tgz",
-      "integrity": "sha512-+ZVWud2HKh+Ob6k/qiJWjBtUg4KmJGGmbvEXXW1SNKS7hW7HU+Zq2ZCcE1akFxOPkVB+EhOty/sSek30tkCYug==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.27.tgz",
+      "integrity": "sha512-8CK3++foRZJluOWXpllG5zwAVlxtv36NpHfsbWS7TYlD8S+QruXltKlXToc/5ZNzBK++l6rvRKELu/puCLc7jA==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-32": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.25.tgz",
-      "integrity": "sha512-3OP/lwV3kCzEz45tobH9nj+uE4ubhGsfx+tn0L26WAGtUbmmcRpqy7XRG/qK7h1mClZ+eguIANcQntYMdYklfw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.27.tgz",
+      "integrity": "sha512-qhNYIcT+EsYSBClZ5QhLzFzV5iVsP1YsITqblSaztr3+ZJUI+GoK8aXHyzKd7/CKKuK93cxEMJPpfi1dfsOfdw==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.25.tgz",
-      "integrity": "sha512-+aKHdHZmX9qwVlQmu5xYXh7GsBFf4TWrePgeJTalhXHOG7NNuUwoHmketGiZEoNsWyyqwH9rE5BC+iwcLY30Ug==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.27.tgz",
+      "integrity": "sha512-ESjck9+EsHoTaKWlFKJpPZRN26uiav5gkI16RuI8WBxUdLrrAlYuYSndxxKgEn1csd968BX/8yQZATYf/9+/qg==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-arm": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.25.tgz",
-      "integrity": "sha512-aTLcE2VBoLydL943REcAcgnDi3bHtmULSXWLbjtBdtykRatJVSxKMjK9YlBXUZC4/YcNQfH7AxwVeQr9fNxPhw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.27.tgz",
+      "integrity": "sha512-JnnmgUBdqLQO9hoNZQqNHFWlNpSX82vzB3rYuCJMhtkuaWQEmQz6Lec1UIxJdC38ifEghNTBsF9bbe8dFilnCw==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.25.tgz",
-      "integrity": "sha512-UxfenPx/wSZx55gScCImPtXekvZQLI2GW3qe5dtlmU7luiqhp5GWPzGeQEbD3yN3xg/pHc671m5bma5Ns7lBHw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.27.tgz",
+      "integrity": "sha512-no6Mi17eV2tHlJnqBHRLekpZ2/VYx+NfGxKcBE/2xOMYwctsanCaXxw4zapvNrGE9X38vefVXLz6YCF8b1EHiQ==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-mips64le": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.25.tgz",
-      "integrity": "sha512-wLWYyqVfYx9Ur6eU5RT92yJVsaBGi5RdkoWqRHOqcJ38Kn60QMlcghsKeWfe9jcYut8LangYZ98xO1LxIoSXrQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.27.tgz",
+      "integrity": "sha512-NolWP2uOvIJpbwpsDbwfeExZOY1bZNlWE/kVfkzLMsSgqeVcl5YMen/cedRe9mKnpfLli+i0uSp7N+fkKNU27A==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-ppc64le": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.25.tgz",
-      "integrity": "sha512-0dR6Csl6Zas3g4p9ULckEl8Mo8IInJh33VCJ3eaV1hj9+MHGdmDOakYMN8MZP9/5nl+NU/0ygpd14cWgy8uqRw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.27.tgz",
+      "integrity": "sha512-/7dTjDvXMdRKmsSxKXeWyonuGgblnYDn0MI1xDC7J1VQXny8k1qgNp6VmrlsawwnsymSUUiThhkJsI+rx0taNA==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-riscv64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.25.tgz",
-      "integrity": "sha512-J4d20HDmTrgvhR0bdkDhvvJGaikH3LzXQnNaseo8rcw9Yqby9A90gKUmWpfwqLVNRILvNnAmKLfBjCKU9ajg8w==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.27.tgz",
+      "integrity": "sha512-D+aFiUzOJG13RhrSmZgrcFaF4UUHpqj7XSKrIiCXIj1dkIkFqdrmqMSOtSs78dOtObWiOrFCDDzB24UyeEiNGg==",
+      "dev": true,
       "optional": true
     },
     "esbuild-linux-s390x": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.25.tgz",
-      "integrity": "sha512-YI2d5V6nTE73ZnhEKQD7MtsPs1EtUZJ3obS21oxQxGbbRw1G+PtJKjNyur+3t6nzHP9oTg6GHQ3S3hOLLmbDIQ==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.27.tgz",
+      "integrity": "sha512-CD/D4tj0U4UQjELkdNlZhQ8nDHU5rBn6NGp47Hiz0Y7/akAY5i0oGadhEIg0WCY/HYVXFb3CsSPPwaKcTOW3bg==",
+      "dev": true,
       "optional": true
     },
     "esbuild-netbsd-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.25.tgz",
-      "integrity": "sha512-TKIVgNWLUOkr+Exrye70XTEE1lJjdQXdM4tAXRzfHE9iBA7LXWcNtVIuSnphTqpanPzTDFarF0yqq4kpbC6miA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.27.tgz",
+      "integrity": "sha512-h3mAld69SrO1VoaMpYl3a5FNdGRE/Nqc+E8VtHOag4tyBwhCQXxtvDDOAKOUQexBGca0IuR6UayQ4ntSX5ij1Q==",
+      "dev": true,
       "optional": true
     },
     "esbuild-openbsd-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.25.tgz",
-      "integrity": "sha512-QgFJ37A15D7NIXBTYEqz29+uw3nNBOIyog+3kFidANn6kjw0GHZ0lEYQn+cwjyzu94WobR+fes7cTl/ZYlHb1A==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.27.tgz",
+      "integrity": "sha512-xwSje6qIZaDHXWoPpIgvL+7fC6WeubHHv18tusLYMwL+Z6bEa4Pbfs5IWDtQdHkArtfxEkIZz77944z8MgDxGw==",
+      "dev": true,
       "optional": true
     },
     "esbuild-sunos-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.25.tgz",
-      "integrity": "sha512-rmWfjUItYIVlqr5EnTH1+GCxXiBOC42WBZ3w++qh7n2cS9Xo0lO5pGSG2N+huOU2fX5L+6YUuJ78/vOYvefeFw==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.27.tgz",
+      "integrity": "sha512-/nBVpWIDjYiyMhuqIqbXXsxBc58cBVH9uztAOIfWShStxq9BNBik92oPQPJ57nzWXRNKQUEFWr4Q98utDWz7jg==",
+      "dev": true,
       "optional": true
     },
     "esbuild-windows-32": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.25.tgz",
-      "integrity": "sha512-HGAxVUofl3iUIz9W10Y9XKtD0bNsK9fBXv1D55N/ljNvkrAYcGB8YCm0v7DjlwtyS6ws3dkdQyXadbxkbzaKOA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.27.tgz",
+      "integrity": "sha512-Q9/zEjhZJ4trtWhFWIZvS/7RUzzi8rvkoaS9oiizkHTTKd8UxFwn/Mm2OywsAfYymgUYm8+y2b+BKTNEFxUekw==",
+      "dev": true,
       "optional": true
     },
     "esbuild-windows-64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.25.tgz",
-      "integrity": "sha512-TirEohRkfWU9hXLgoDxzhMQD1g8I2mOqvdQF2RS9E/wbkORTAqJHyh7wqGRCQAwNzdNXdg3JAyhQ9/177AadWA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.27.tgz",
+      "integrity": "sha512-b3y3vTSl5aEhWHK66ngtiS/c6byLf6y/ZBvODH1YkBM+MGtVL6jN38FdHUsZasCz9gFwYs/lJMVY9u7GL6wfYg==",
+      "dev": true,
       "optional": true
     },
     "esbuild-windows-arm64": {
-      "version": "0.14.25",
-      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.25.tgz",
-      "integrity": "sha512-4ype9ERiI45rSh+R8qUoBtaj6kJvUOI7oVLhKqPEpcF4Pa5PpT3hm/mXAyotJHREkHpM87PAJcA442mLnbtlNA==",
+      "version": "0.14.27",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.27.tgz",
+      "integrity": "sha512-I/reTxr6TFMcR5qbIkwRGvldMIaiBu2+MP0LlD7sOlNXrfqIl9uNjsuxFPGEG4IRomjfQ5q8WT+xlF/ySVkqKg==",
+      "dev": true,
       "optional": true
     },
     "eventemitter3": {
@@ -961,6 +1193,11 @@
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
       "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
     },
+    "exenv": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
+      "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50="
+    },
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -997,6 +1234,19 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "dev": true
     },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "requires": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      }
+    },
     "make-error": {
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1012,6 +1262,11 @@
         "brace-expansion": "^1.1.7"
       }
     },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    },
     "once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1050,6 +1305,56 @@
       "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz",
       "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg=="
     },
+    "prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "requires": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "react": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
+      "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      }
+    },
+    "react-dom": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
+      "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "scheduler": "^0.20.2"
+      }
+    },
+    "react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+    },
+    "react-lifecycles-compat": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
+    },
+    "react-modal": {
+      "version": "3.14.4",
+      "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.14.4.tgz",
+      "integrity": "sha512-8surmulejafYCH9wfUmFyj4UfbSJwjcgbS9gf3oOItu4Hwd6ivJyVBETI0yHRhpJKCLZMUtnhzk76wXTsNL6Qg==",
+      "requires": {
+        "exenv": "^1.2.0",
+        "prop-types": "^15.7.2",
+        "react-lifecycles-compat": "^3.0.0",
+        "warning": "^4.0.3"
+      }
+    },
     "rimraf": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -1067,6 +1372,15 @@
         "sdp": "^2.6.0"
       }
     },
+    "scheduler": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
+      "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      }
+    },
     "sdp": {
       "version": "2.12.0",
       "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
@@ -1105,6 +1419,14 @@
       "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==",
       "dev": true
     },
+    "warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "requires": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "web-animations-js": {
       "version": "2.3.2",
       "resolved": "https://registry.npmjs.org/web-animations-js/-/web-animations-js-2.3.2.tgz",
diff --git a/package.json b/package.json
index 69750b8..8f56050 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,9 @@
   "description": "Dao (game)",
   "main": "app.js",
   "scripts": {
-    "clean": "rimraf public/bundle.js",
-    "build": "ts-node tools/esbuild.ts",
-    "build-watch": "ts-node tools/esbuild-watch.ts",
-    "start": "ts-node tools/esbuild-build-and-serve.ts",
+    "clean": "rimraf public/dist",
+    "build": "ts-node tools/build.ts",
+    "start": "ts-node tools/build-and-serve.ts",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "repository": {
@@ -22,12 +21,17 @@
   "homepage": "https://gitlab.com/fuzzy-rp/dao-game#readme",
   "dependencies": {
     "detect-browser": "^5.3.0",
-    "esbuild": "^0.14.25",
     "peerjs": "^1.3.2",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "react-modal": "^3.14.4",
     "web-animations-js": "^2.3.2"
   },
   "devDependencies": {
-    "@types/node": "^17.0.21",
+    "@types/node": "^17.0.23",
+    "@types/react": "^17.0.42",
+    "@types/react-modal": "^3.13.1",
+    "esbuild": "^0.14.27",
     "rimraf": "^3.0.2",
     "ts-node": "^10.6.0",
     "typescript": "^4.6.2"
diff --git a/public/index.html b/public/index.html
index adba5c1..dd30114 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,7 +7,7 @@
   <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/index.js"></script>
+  <script defer src="dist/index.js"></script>
 </head>
 
 <body>
diff --git a/public/local.html b/public/local.html
index 4c9952d..278cba7 100644
--- a/public/local.html
+++ b/public/local.html
@@ -7,7 +7,7 @@
     <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>
+    <script defer src="dist/pages/local.js"></script>
 </head>
 
 <body>
diff --git a/public/react.html b/public/react.html
new file mode 100644
index 0000000..829b0a5
--- /dev/null
+++ b/public/react.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8" />
+    <title>Dao Game in React</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <link rel="icon" href="yin-yang.svg" type="image/svg+xml" />
+    <script defer src="dist/app.js"></script>
+    <link rel="stylesheet" type="text/css" href="./dist/app.css" />
+</head>
+
+<body>
+    <div id="root"></div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/public/remote.html b/public/remote.html
index 4f57b0d..a509cd8 100644
--- a/public/remote.html
+++ b/public/remote.html
@@ -7,7 +7,7 @@
   <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/remote.js"></script>
+  <script defer src="dist/pages/remote.js"></script>
 </head>
 
 <body>
diff --git a/readme.md b/readme.md
index 269c59e..8c5b737 100644
--- a/readme.md
+++ b/readme.md
@@ -30,3 +30,5 @@ This project is using [esbuild](https://esbuild.github.io/getting-started/#build
 4. Add computer opponent using simple minimax logic.
    1. [https://www.neverstopbuilding.com/blog/minimax](https://www.neverstopbuilding.com/blog/minimax)
    2. [https://en.wikipedia.org/wiki/Minimax](https://en.wikipedia.org/wiki/Minimax)
+5. Add live reload support
+   1. [https://how-to.dev/how-to-add-live-reload-to-esbuild-server](https://how-to.dev/how-to-add-live-reload-to-esbuild-server)
diff --git a/src/app.tsx b/src/app.tsx
new file mode 100644
index 0000000..c1f32fa
--- /dev/null
+++ b/src/app.tsx
@@ -0,0 +1,11 @@
+import React, { useCallback, useState } from 'react';
+import ReactDOM from 'react-dom';
+import { Game } from './game/game';
+import { App } from './react/App';
+
+
+const game = new Game('r', 'Red', 'b', 'Blue');
+const app = <App game={game} />
+const root = document.getElementById('root');
+
+ReactDOM.render(app, root);
diff --git a/src/game/coord.ts b/src/game/coord.ts
index ad5c5af..8111037 100644
--- a/src/game/coord.ts
+++ b/src/game/coord.ts
@@ -11,7 +11,7 @@ export class Coord {
         return new Coord(this.row + coord.row, this.col + coord.col);
     }
 
-    sub(coord: Coord): Coord {
+    public sub(coord: Coord): Coord {
         return new Coord(this.row - coord.row, this.col - coord.col);
     }
 
@@ -19,20 +19,20 @@ export class Coord {
      * Finds if otherCoord is contained within a rectangle that sits between origin and this Coord
      * @param otherCoord
      */
-    contains(otherCoord: Coord): boolean {
+    public 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;
     }
 
-    equals(otherCoord: Coord): boolean {
+    public equals(otherCoord: Coord): boolean {
         const areEqual = this.col === otherCoord.col && this.row === otherCoord.row;
         return areEqual;
     }
 
     // clock face directions
-    static get allDirections(): Coord[] {
+    public static get allDirections(): Coord[] {
         const allDirections = [
             new Coord(1, 0), // up
             new Coord(1, 1), // up-right
diff --git a/src/game/game-board.ts b/src/game/game-board.ts
index dd1d14a..90544a6 100644
--- a/src/game/game-board.ts
+++ b/src/game/game-board.ts
@@ -1,42 +1,41 @@
 import { Coord } from "./coord";
 
 export class GameBoard {
-  private board: (string | null)[];
+  public 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 = testBoard;
+    const defaultBoard = ([] as (string | null)[]).concat.apply([], [
+      [player1Id, null, null, player2Id],
+      [null, player1Id, player2Id, null],
+      [null, player2Id, player1Id, null],
+      [player2Id, null, null, player1Id]]);
+  const testBoard = ([] as (string | null)[]).concat.apply([], [
+      [player1Id, null, player2Id, player2Id],
+      [player1Id, null, player2Id, null],
+      [player1Id, null, null, null],
+      [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)
+    const piece = this.getPiece(source);
+    if (piece === null)
       throw `Source ${source} points to an empty space`;
-    this.set(source, null);
-    this.set(dest, isP1);
+    this.setPiece(source, null);
+    this.setPiece(dest, piece);
   }
-  public get(coord: Coord): boolean | null {
+  public getPiece(coord: Coord): string | 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;
+    return id;
   }
   public getCoords(id: string): Coord[] {
     const coords = this.board
@@ -44,21 +43,25 @@ export class GameBoard {
         const isTarget = pieceId === id;
         return isTarget ? this.boardIndexToCoord(index) : null;
       })
-      .filter((coord) => coord !== null);
+      .filter((coord) => coord !== null)
+      .map(coord => coord!);
     return coords;
   }
-  private set(coord: Coord, isP1: boolean | null): void {
+  private setPiece(coord: Coord, piece: string | null): void {
+    const { player1Id, player2Id } = this;
+    if (piece !== null && piece !== player1Id && piece !== player2Id)
+      throw `Invalid piece specified: ${piece}`;
     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;
+    this.board[index] = piece;
   }
   public coordToBoardIndex(coord: Coord): number {
     const { row, col } = coord;
     const index = row * this.size.col + col;
     return index;
   }
-  private boardIndexToCoord(index: number): Coord {
+  public 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/game.ts b/src/game/game.ts
index 2f4f2bc..c401292 100644
--- a/src/game/game.ts
+++ b/src/game/game.ts
@@ -26,7 +26,7 @@ export interface IGame {
   getValidMoves: (start: Coord) => Coord[];
   makeMove: (source: Coord, dest: Coord) => Promise<MoveResult>;
   undoMove: () => Move;
-  getPiece(pos: Coord): string | undefined;
+  getPiece(pos: Coord): string | null;
 }
 
 export class Game implements IGame {
@@ -48,11 +48,9 @@ export class Game implements IGame {
     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;
+  getPiece(pos: Coord): string | null {
+    const id = this.gameBoard.getPiece(pos);
+    if (id === null) return null;
     return id;
   }
   public currentPlayerId: string;
@@ -126,6 +124,7 @@ export class Game implements IGame {
     };
 
     const { gameBoard, player1Id, player2Id } = this;
+
     const isP1Turn = this.currentPlayerId === player1Id;
     const playerId = isP1Turn ? player1Id : player2Id;
 
@@ -141,7 +140,7 @@ export class Game implements IGame {
     return result;
   }
   public getValidMoves(start: Coord): Coord[] {
-    const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord => {
+    const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord | undefined => {
       const { gameBoard } = this;
       let farthestPos = undefined;
       let testPos = startCoord;
@@ -152,7 +151,7 @@ export class Game implements IGame {
         if (!withinBounds) {
           break;
         }
-        const isOccupied = gameBoard.get(pos) !== null;
+        const isOccupied = gameBoard.getPiece(pos) !== null;
         if (isOccupied) {
           break;
         }
@@ -165,7 +164,8 @@ export class Game implements IGame {
 
     const farthestCoords = Coord.allDirections
       .map((direction) => getFarthestCoord(start, direction))
-      .filter((farCoord) => farCoord !== undefined);
+      .filter((farCoord) => farCoord !== undefined)
+      .map(fc => fc!);
 
     return farthestCoords;
   }
@@ -174,7 +174,7 @@ export class Game implements IGame {
       throw "Newp! Can't undo!";
     }
     const { gameBoard } = this;
-    const move = this._moveHistory.pop();
+    const move = this._moveHistory.pop()!;
     const { source, dest, playerId } = move;
     this.currentPlayerId = playerId;
     gameBoard.move(dest, source);
diff --git a/src/game/local-game.ts b/src/game/local-game.ts
index 33fb9c6..5462819 100644
--- a/src/game/local-game.ts
+++ b/src/game/local-game.ts
@@ -16,8 +16,8 @@ export class LocalGame extends Game {
 
   // DOM references
   readonly dom = {
-    defaultP1Piece: document.getElementById('default-p1-piece'),
-    defaultP2Piece: document.getElementById('default-p2-piece'),
+    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')),
@@ -83,7 +83,7 @@ export class LocalGame extends Game {
     await Promise.all([sourceAnimation.finished, destAnimation.finished]);
 
     // remove old piece
-    piece.parentNode.removeChild(piece);
+    piece.parentNode!.removeChild(piece);
   }
 
   public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string) {
@@ -196,7 +196,7 @@ export class LocalGame extends Game {
 
     // create board and pieces
     let isWhite = true;
-    const table = document.getElementById('table');
+    const table = document.getElementById('table')!;
     for (let r = 0; r < size.row; r++) {
       const tr = document.createElement('tr');
       table.appendChild(tr);
diff --git a/src/game/remote-game.ts b/src/game/remote-game.ts
index 0549c28..086e498 100644
--- a/src/game/remote-game.ts
+++ b/src/game/remote-game.ts
@@ -11,8 +11,8 @@ import { copyToClipboard } from '../utils/clipboard';
 import { detect } from 'detect-browser';
 
 interface LocalData {
-    id: string;
-    peerId: string;
+    id?: string;
+    peerId?: string;
 }
 interface Pos {
     x: number;
@@ -24,9 +24,9 @@ interface PeerData {
 }
 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 otherMouseElement = document.getElementById('other-mouse')!;
+const shareContainer = document.getElementById('share-container')!;
+const shareLinkButton = document.getElementById('share-link')!;
 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) => {
@@ -42,7 +42,7 @@ const dropFileExtension = (path: string) => {
     const dirName = path.substring(0, lastSlashIndex);
     return dirName;
 }
-const showMessageAndLeave = async (title, text) => {
+const showMessageAndLeave = async (title: string, text: string) => {
     await showModal({ title, text, willBackgroundCancel: false });
     sessionStorage.clear();
     const { protocol, host, pathname } = location;
@@ -76,7 +76,7 @@ export class PeerGame extends LocalGame {
     public override async handleMoveResult(result: MoveResult): Promise<void> {
         const { isTerminal, message } = result;
         if (isTerminal) {
-            await showMessageAndLeave('Game Over', message);
+            await showMessageAndLeave('Game Over', message!);
         } else {
             this.resetUi();
             this.setActiveUi();
@@ -86,7 +86,7 @@ export class PeerGame extends LocalGame {
     public static async createPeerGame(): Promise<PeerGame> {
         let { id, peerId } = getData();
         const main = document.getElementsByTagName('main')[0];
-        let game: PeerGame = undefined;
+        let game: PeerGame | undefined = undefined;
 
         const handleConn = async (conn: Peer.DataConnection) => {
             shareContainer.classList.add('invisible');
@@ -107,7 +107,7 @@ export class PeerGame extends LocalGame {
             game.setActiveUi();
             main.classList.remove('invisible');
 
-            const table = document.getElementById('table');
+            const table = document.getElementById('table')!;
             table.addEventListener('mousemove', (ev: MouseEvent) => {
                 const xTable = ev.clientX - table.offsetLeft;
                 const yTable = ev.clientY - table.offsetTop;
@@ -121,8 +121,10 @@ export class PeerGame extends LocalGame {
                 conn.send(JSON.stringify(peerData));
             });
 
-            let lastTimerId = undefined;
+            let lastTimerId: NodeJS.Timeout | undefined = undefined;
             conn.on('data', (data: string) => {
+                if (!game) return;
+
                 const peerData = JSON.parse(data) as PeerData;
                 const { mousePos, move } = peerData;
 
diff --git a/src/react/App.css b/src/react/App.css
new file mode 100644
index 0000000..1e0e319
--- /dev/null
+++ b/src/react/App.css
@@ -0,0 +1,25 @@
+@import url('./Base.css');
+
+.grid-container {
+    display: grid;
+    grid-template-areas:
+        '0 1 2 3'
+        '4 5 6 7'
+        '8 9 10 11'
+        '12 13 14 15';
+    width: calc(var(--cell-size) * 4);
+    height: calc(var(--cell-size) * 4);
+    margin: auto;
+}
+
+.modal {
+    font-family: Arial, Helvetica, sans-serif;
+    font-size: xx-large;
+    /* margin: 100px 100px 100px 100px; */
+    margin: auto;
+    text-align: center;
+    background-color: white;
+    user-select: none;
+    border: 1px solid black;
+    padding: 20px;
+}
diff --git a/src/react/App.tsx b/src/react/App.tsx
new file mode 100644
index 0000000..1218741
--- /dev/null
+++ b/src/react/App.tsx
@@ -0,0 +1,111 @@
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+import { Coord } from '../game/coord';
+import { IGame, MoveResult } from '../game/game';
+import Modal from 'react-modal';
+import './App.css';
+import { Tile } from './Tile';
+import { Controls } from './Controls';
+import { delay } from '../utils/delay';
+import { Status } from './Status';
+
+export type Props = {
+    game: IGame;
+};
+
+export const App = (props: Props): React.ReactElement => {
+    const { game } = props;
+    const [selectedPiecePos, setSelectedPiecePos] = useState<Coord>()
+    const [validMoves, setValidMoves] = useState<Coord[]>([]);
+    const [currentPlayerId, setCurrentPlayerId] = useState<string>(game.currentPlayerId);
+    const [moveResult, setMoveResult] = useState<MoveResult>();
+    const [isModalOpen, setIsModalOpen] = useState(false);
+    const [isControlsDisabled, setIsControlsDisabled] = useState(!game.canUndoMove);
+
+    useEffect((): void => {
+        const slowUpdate = async () => {
+            if (moveResult && moveResult.isTerminal) {
+                await delay(750);
+                setIsModalOpen(true);
+            }
+        };
+        slowUpdate();
+    }, [moveResult]);
+
+    const makeMove = async (from: Coord, to: Coord) => {
+        const result = await game.makeMove(from, to);
+        setCurrentPlayerId(game.currentPlayerId);
+        setSelectedPiecePos(undefined);
+        setValidMoves([]);
+        setMoveResult(result);
+        setIsControlsDisabled(false);
+    };
+    const undoMove = () => {
+        const move = game.undoMove();
+        console.debug({ move });
+        setCurrentPlayerId(game.currentPlayerId);
+        setSelectedPiecePos(undefined);
+        setValidMoves([]);
+        setMoveResult(undefined);
+        setIsControlsDisabled(!game.canUndoMove);
+    };
+
+    const allTiles: React.ReactElement[] = [];
+    for (let r = 0; r < game.size.row; r++) {
+        for (let c = 0; c < game.size.col; c++) {
+            const pos = new Coord(r, c);
+            const pieceClickCb = () => {
+                const isUnselectClick = selectedPiecePos !== undefined && selectedPiecePos.equals(pos);
+                if (isUnselectClick) {
+                    setSelectedPiecePos(undefined);
+                    setValidMoves([]);
+                } else {
+                    setSelectedPiecePos(pos);
+                    setValidMoves([...game.getValidMoves(pos)]);
+                }
+            };
+            const validMoveClickCb = async () => {
+                await makeMove(selectedPiecePos!, pos)
+            };
+            const tile =
+                <Tile
+                    pos={pos}
+                    game={game}
+                    validMoves={validMoves}
+                    selectedPiecePos={selectedPiecePos}
+                    currentPlayerId={currentPlayerId}
+                    pieceClickCb={() => pieceClickCb()}
+                    validMoveClickCb={() => validMoveClickCb()}
+                    key={game.coordToBoardIndex(pos)}
+                />;
+            allTiles.push(tile);
+        }
+    }
+
+    return (
+        <div>
+            <Status
+                currentPlayerId={currentPlayerId}
+                player1Id={game.player1Id}
+                player1Name={game.player1Name}
+                player2Id={game.player2Id}
+                player2Name={game.player2Name}
+            />
+            <div className='grid-container'>
+                {allTiles}
+            </div>
+            <Controls onUndo={undoMove} isDisabled={isControlsDisabled} />
+            <Modal isOpen={isModalOpen} className='modal'>
+                <h2>Game Over</h2>
+                <div>
+                    {moveResult?.message}
+                </div>
+                <div>
+                    <button onClick={() => location.reload()}>
+                        Restart game
+                    </button>
+                </div>
+            </Modal>
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/src/react/Base.css b/src/react/Base.css
new file mode 100644
index 0000000..f76082d
--- /dev/null
+++ b/src/react/Base.css
@@ -0,0 +1,29 @@
+:root {
+  --default-size: 5px;
+  --active-size: 40px;
+  --active-pulse-size: 30px;
+  --cell-size: 80px;
+  --max-width: 500px;
+  --font-family: Arial, sans-serif;
+}
+
+button {
+    border-radius: 10px;
+    padding: 10px;
+    background-color: orange;
+    font-size: xx-large;
+    border-color: transparent;
+    transition: 0.2s all;
+    margin: 30px;
+}
+button:hover:enabled {
+  transform: scale(1.02);
+  box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24);
+}
+button:active:enabled {
+  transform: scale(0.98);
+  box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24);
+}
+button:disabled {
+  background-color: gainsboro;
+}
\ No newline at end of file
diff --git a/src/react/Controls.css b/src/react/Controls.css
new file mode 100644
index 0000000..9c584fe
--- /dev/null
+++ b/src/react/Controls.css
@@ -0,0 +1,31 @@
+@import url('./Base.css');
+
+.container {
+  margin: auto;
+  padding: 50px;
+}
+
+.controls {
+  font-size: 90px;
+  font-family: var(--font-family);
+  font-size: 55px;
+  border-radius: 10px;
+  padding: 10px;
+  max-width: var(--max-width);
+  display: flex;
+  margin: auto;
+  /* box-sizing: border-box; */
+  /* text-align: center; */
+}
+
+.undo-button {
+  /* display: inline-block; */
+  float: left;
+  margin: auto;
+}
+
+.reset-button {
+  /* display: inline-block; */
+  float: right;
+  margin: auto;
+}
diff --git a/src/react/Controls.tsx b/src/react/Controls.tsx
new file mode 100644
index 0000000..b878f6c
--- /dev/null
+++ b/src/react/Controls.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import './Controls.css';
+
+export type Props = {
+    isDisabled: boolean
+    onUndo: () => void
+};
+
+const reload = () => location.reload();
+
+export const Controls = (props: Props): React.ReactElement => {
+    const { isDisabled, onUndo } = props;
+    return <div className='container'>
+        <div className='controls'>
+            <button
+                disabled={isDisabled}
+                type='button'
+                onClick={onUndo}
+                className='undo-button'>Undo</button>
+            <button
+                disabled={isDisabled}
+                type='button'
+                onClick={reload}
+                className='reset-button'>Reset</button>
+        </div>
+    </div>;
+}
\ No newline at end of file
diff --git a/src/react/Piece.css b/src/react/Piece.css
new file mode 100644
index 0000000..2b97b9f
--- /dev/null
+++ b/src/react/Piece.css
@@ -0,0 +1,51 @@
+@import url('./Base.css');
+
+.piece {
+  width: var(--default-size);
+  height: var(--default-size);
+  padding: 10px;
+  border-radius: 50px;
+  animation: activeToDisabled .5s 1;
+}
+
+.p1-piece {
+  background-color: crimson;
+}
+.p2-piece {
+  background-color: mediumblue;
+}
+
+.active-player-piece {
+  width: var(--active-size);
+  height: var(--active-size);
+  animation: disabledToActive .5s 1;
+}
+.active-player-piece:hover {
+  filter: drop-shadow(1px 1px 5px black);
+}
+
+.selected-piece {
+  border: 2px white solid;
+  filter: drop-shadow(1px 1px 5px black);
+}
+
+@keyframes disabledToActive {
+  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);
+  }
+}
diff --git a/src/react/Piece.tsx b/src/react/Piece.tsx
new file mode 100644
index 0000000..af54f4b
--- /dev/null
+++ b/src/react/Piece.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import './Piece.css';
+
+export type Props = {
+    isPlayer1: boolean,
+    isCurrentPlayer: boolean,
+    isSelectedPiece: boolean,
+    clickCb: () => void
+}
+export const Piece = (props: Props): React.ReactElement => {
+    const { isPlayer1, isCurrentPlayer, isSelectedPiece } = props;
+    const classNames = [
+        'piece',
+        isPlayer1 ? 'p1-piece' : 'p2-piece',
+        isCurrentPlayer && 'active-player-piece',
+        isSelectedPiece && 'selected-piece'
+    ];
+
+    return (
+        <div className={classNames.join(' ')} onClick={props.clickCb} />
+    )
+};
diff --git a/src/react/Status.css b/src/react/Status.css
new file mode 100644
index 0000000..0572354
--- /dev/null
+++ b/src/react/Status.css
@@ -0,0 +1,34 @@
+@import url('./Base.css');
+
+.status {
+  font-family: var(--font-family);
+  font-size: 55px;
+  border-radius: 10px;
+  padding: 10px;
+  max-width: var(--max-width);
+  display: flex;
+  margin: auto;
+}
+
+.p1-turn,
+.p2-turn {
+  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;
+}
+
+.active {
+    color: white;
+}
+.p1-turn.active {
+  background-color: red;
+}
+.p2-turn.active {
+  background-color: blue;
+}
\ No newline at end of file
diff --git a/src/react/Status.tsx b/src/react/Status.tsx
new file mode 100644
index 0000000..1f3ef5a
--- /dev/null
+++ b/src/react/Status.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import './Status.css';
+
+export type Props = {
+    currentPlayerId: string
+    player1Id: string
+    player1Name: string
+    player2Id: string
+    player2Name: string
+};
+export const Status = (props: Props): React.ReactElement => {
+
+    const { currentPlayerId, player1Id, player1Name, player2Name } = props;
+    const isPlayer1 = currentPlayerId === player1Id;
+
+    return (
+        <div className='status'>
+            <span className={`p1-turn ${isPlayer1 ? 'active' : ''}`}>{player1Name} turn</span>
+            <span className={`p2-turn ${isPlayer1 ? '' : 'active'}`}>{player2Name} turn</span>
+        </div>
+    );
+};
diff --git a/src/react/Tile.css b/src/react/Tile.css
new file mode 100644
index 0000000..166b474
--- /dev/null
+++ b/src/react/Tile.css
@@ -0,0 +1,32 @@
+@import url('./Base.css');
+
+.tile {
+  width: var(--cell-size);
+  height: var(--cell-size);
+  text-align: center;
+  border: 2px transparent solid;
+
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin: auto;
+}
+
+.white {
+  background-color: orange;
+}
+
+.black {
+  background-color: wheat;
+}
+
+/* .valid-move {
+  border-color: black;
+  filter: drop-shadow(0px 0px 5px black);
+} */
+
+.possible-move {
+  animation: blinkingText 1s infinite;
+  border-color: black;
+  filter: drop-shadow(0px 0px 5px green);
+}
diff --git a/src/react/Tile.tsx b/src/react/Tile.tsx
new file mode 100644
index 0000000..2229a15
--- /dev/null
+++ b/src/react/Tile.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { Coord } from '../game/coord';
+import { IGame } from '../game/game';
+import { Piece } from './Piece';
+import './Tile.css';
+
+export type Props = {
+    pos: Coord,
+    game: IGame,
+    validMoves: Coord[],
+    selectedPiecePos: Coord | undefined,
+    currentPlayerId: string,
+    pieceClickCb: () => void,
+    validMoveClickCb: () => void
+};
+export const Tile = (props: Props): React.ReactElement => {
+
+    const { pos, game, validMoves, selectedPiecePos, currentPlayerId } = props;
+    const { player1Id } = game;
+    const { row, col } = pos;
+
+    const isWhite = row % 2 === col % 2;
+    const piece = game.getPiece(pos);
+    const isPlayer1 = piece === player1Id;
+    const isCurrentPlayer = piece === currentPlayerId;
+    const isSelectedPiece = selectedPiecePos !== undefined && pos.equals(selectedPiecePos);
+    const isValidMove = validMoves.findIndex(vm => vm.equals(pos)) >= 0;
+    const classNames = [
+        'tile',
+        isWhite ? 'white' : 'black',
+        isValidMove && 'possible-move'
+    ];
+    return (
+        <div
+            className={classNames.join(' ')}
+            onClick={() => { if (isValidMove) { props.validMoveClickCb(); } }}>
+            {(piece !== null) &&
+                <Piece
+                    isPlayer1={isPlayer1}
+                    isCurrentPlayer={isCurrentPlayer}
+                    clickCb={() => { if (isCurrentPlayer) { props.pieceClickCb() } }}
+                    isSelectedPiece={isSelectedPiece}
+                />}
+        </div>
+    );
+};
diff --git a/tools/esbuild-build-and-serve.ts b/tools/build-and-serve.ts
similarity index 97%
rename from tools/esbuild-build-and-serve.ts
rename to tools/build-and-serve.ts
index d2fa260..543f4c6 100644
--- a/tools/esbuild-build-and-serve.ts
+++ b/tools/build-and-serve.ts
@@ -1,6 +1,6 @@
 import esbuild from 'esbuild';
 import http from 'http';
-import { serveOptions, buildOptions, hostPort, clearOutDir } from './esbuild-settings'
+import { serveOptions, buildOptions, hostPort, clearOutDir } from './settings'
 
 clearOutDir();
 
diff --git a/tools/build.ts b/tools/build.ts
new file mode 100644
index 0000000..d8857df
--- /dev/null
+++ b/tools/build.ts
@@ -0,0 +1,6 @@
+import esbuild from 'esbuild';
+import { buildOptions, clearOutDir } from './settings'
+
+clearOutDir();
+
+esbuild.build(buildOptions).catch(() => process.exit(1))
\ No newline at end of file
diff --git a/tools/esbuild-watch.ts b/tools/esbuild-watch.ts
deleted file mode 100644
index 49a3d03..0000000
--- a/tools/esbuild-watch.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import esbuild from 'esbuild';
-import { buildOptions, clearOutDir } from './esbuild-settings'
-
-clearOutDir();
-
-esbuild.build(buildOptions).catch(() => process.exit(1))
\ No newline at end of file
diff --git a/tools/esbuild.ts b/tools/esbuild.ts
deleted file mode 100644
index b02895b..0000000
--- a/tools/esbuild.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import esbuild from 'esbuild';
-import { serveOptions, buildOptions, hostPort, clearOutDir } from './esbuild-settings'
-
-clearOutDir();
-
-esbuild.build(buildOptions).catch(() => process.exit(1))
\ No newline at end of file
diff --git a/tools/esbuild-settings.ts b/tools/settings.ts
similarity index 79%
rename from tools/esbuild-settings.ts
rename to tools/settings.ts
index 48eb1af..57b3fd7 100644
--- a/tools/esbuild-settings.ts
+++ b/tools/settings.ts
@@ -1,24 +1,22 @@
 import esbuild from 'esbuild';
-import fs from 'fs'
+import fs from 'fs';
 
 export const hostPort = 3000;
-const outDir = 'public/js';
+const outDir = 'public/dist';
 
 export const serveOptions: esbuild.ServeOptions = {
     servedir: 'public',
 };
 export const buildOptions: esbuild.BuildOptions = {
-    entryPoints: ['src/pages/local.ts', 'src/pages/remote.ts', 'src/pages/index.ts'],
-    bundle: true,
+    logLevel: "info",
+    entryPoints: ['src/pages/local.ts', 'src/pages/remote.ts', 'src/pages/index.ts', 'src/app.tsx'],
     outdir: outDir,
+    bundle: true,
     sourcemap: true,
     target: [
         'es5',
         'es2016'
-    ],
-    loader: {
-        '.ts': 'ts'
-    }
+    ]
 };
 export function clearOutDir() {
     fs.rmSync(outDir, { recursive: true, force: true });
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e33beee
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,93 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig.json to read more about this file */
+    /* Projects */
+    // "incremental": true,                              /* Enable incremental compilation */
+    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+    // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
+    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
+    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+    /* Language and Environment */
+    "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+    "jsx": "react", /* Specify what JSX code is generated. */
+    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
+    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
+    // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
+    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+    /* Modules */
+    "module": "commonjs", /* Specify what module code is generated. */
+    "rootDir": "src", /* Specify the root folder within your source files. */
+    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+    // "typeRoots": [],                                  /* Specify multiple folders that act like `./node_modules/@types`. */
+    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+    // "resolveJsonModule": true,                        /* Enable importing .json files */
+    // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
+    /* JavaScript Support */
+    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
+    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
+    /* Emit */
+    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
+    // "outDir": "./",                                   /* Specify an output folder for all emitted files. */
+    // "removeComments": true,                           /* Disable emitting comments. */
+    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
+    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+    // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
+    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
+    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
+    // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
+    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+    // "preserveValueImports": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
+    /* Interop Constraints */
+    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+    "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
+    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+    "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+    /* Type Checking */
+    "strict": true, /* Enable all strict type-checking options. */
+    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied `any` type.. */
+    // "strictNullChecks": true,                         /* When type checking, take into account `null` and `undefined`. */
+    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+    // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
+    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+    // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
+    // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
+    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+    // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
+    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read */
+    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
+    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
+    // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
+    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
+    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
+    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+    /* Completeness */
+    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+    "skipLibCheck": true /* Skip type checking all .d.ts files. */
+  }
+}
\ No newline at end of file