├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── database.rules.json ├── firebase.json ├── functions ├── .gitignore ├── index.js └── package.json └── public ├── 404.html ├── css └── index.css ├── index.html └── js └── game.js /.gitignore: -------------------------------------------------------------------------------- 1 | /.firebaserc 2 | /firebase-debug.log 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tic Tac Toe with Firebase 2 | 3 | This repository contains the code that implements the Tic Tac Toe game that was presented during [my session at Google I/O 2017](https://www.youtube.com/watch?v=eWj6dxfN63g). 4 | 5 | ## Deploying the project 6 | 7 | This project contains three main components: a web client deployed to Firebase Hosting, server-side logic deployed to Cloud Functions for Firebase, and Realtime Database security rules. 8 | 9 | Here are the steps to deploy (assuming you're already [set up to build and deploy](https://firebase.google.com/docs/functions/get-started) code to Cloud Functions for Firebase on your computer): 10 | 11 | 1. Create a new Firebase project in the [Firebase console](http://console.firebase.google.com/). 12 | 13 | 2. In the console under Authentiction, enable sign-ins with Google auth. 14 | 15 | 3. Clone this repo. 16 | 17 | 4. On the command line, change to the repo root directory. 18 | 19 | 5. Run `firebase init` to initialize the project space. Be sure to select each option (Database, Functions, Hosting) when prompted, then select the project you just created in the console. Take all the subsequent defaults when prompted. 20 | 21 | 6. Run `firebase deploy` to deploy the web content, Cloud Functions, and database security rules. 22 | 23 | 7. When the deploy completes, you'll receive a URL to your project's main page hosted by Firebase Hosting. Copy that into your browser to begin. 24 | 25 | Note that the game requires that two different Google accounts be used to play against each other. So, if you are trying this by yourself, you'll need use two different Google accounts in two different browser windows logged in at the same time. 26 | 27 | ## How it works 28 | 29 | Players must sign in with a Google account before playing. After signing in, a player can indicate that they want to play. If two people are trying to play at the same time, the game will match the two players into a game. 30 | 31 | The client side of the game lives in the `public` directory. When deployed, the content is hosted by [Firebase Hosting](https://firebase.google.com/docs/hosting/). It performs only two primary tasks. First, it provides a UI to navigate and render the state of the game. Second, it issues commands to the backend that express the intent of the player. The client doesn't contain any of the rules of the game, and it makes no attempt to prevent the player from making an invalid move. (Note that this is not necessarily great design, but it simulates the case where the game code has been compromised.) 32 | 33 | The client code expresses the intent of the player by pushing a command under `/commands` into the database that describes the intent (e.g. looking for a player match, or making a move). 34 | 35 | The backend of the game lives in the `functions` directory. When deployed, the code is hosted by [Cloud Functions for Firebase](https://firebase.google.com/docs/functions/). It contains and enforces all the rules of the game. A function is invoked for each client command, the command is processed, and the results are written back to the database, with game data living under `/games`, individual player state under `/player_states`, and matching players under `/matching`. The command data is then deleted. 36 | 37 | ## Author 38 | 39 | My name is Doug Stevenson ([@CodingDoug](https://twitter.com/CodingDoug) on Twitter) and I'm a developer advocate with the Firebase team at Google. I create content about Firebase on the [Firebase Channel on YouTube](https://www.youtube.com/firebase) and the [Firebase Blog](http://firebase.googleblog.com/) and speak at various events. 40 | -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "commands": { 4 | "$uid": { 5 | ".read": false, 6 | ".write": "$uid === auth.uid" 7 | } 8 | } 9 | , 10 | "players": { 11 | "$uid": { 12 | ".read": true, 13 | ".write": "$uid === auth.uid" 14 | } 15 | } 16 | , 17 | "player_states": { 18 | "$uid": { 19 | ".read": "$uid === auth.uid", 20 | ".write": false 21 | } 22 | } 23 | , 24 | "games": { 25 | ".read": true, 26 | ".write": false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "hosting": { 6 | "public": "public" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const functions = require('firebase-functions') 18 | const checkin_period = 20000 19 | 20 | exports.command = functions.database 21 | .ref('/commands/{uid}/{cmd_id}') 22 | .onWrite(event => { 23 | const uid = event.params.uid 24 | const cmd_id = event.params.cmd_id 25 | 26 | if (! event.data.exists()) { 27 | console.log(`command was deleted ${cmd_id}`) 28 | return 29 | } 30 | 31 | const command = event.data.val() 32 | const cmd_name = command.command 33 | console.log(`command ${cmd_name} uid=${uid} cmd_id=${cmd_id}`) 34 | const root = event.data.adminRef.root 35 | let pr_cmd 36 | switch (cmd_name) { 37 | case 'match': 38 | pr_cmd = match(root, uid) 39 | break 40 | case 'move': 41 | pr_cmd = move(root, uid, command) 42 | break 43 | case 'checkin': 44 | pr_cmd = checkin(root, uid) 45 | break 46 | default: 47 | console.log(`Unknown command: ${cmd_name}`) 48 | pr_cmd = Promise.reject("Unknown command") 49 | break 50 | } 51 | 52 | const pr_remove = event.data.adminRef.remove() 53 | return Promise.all([pr_cmd, pr_remove]) 54 | }) 55 | 56 | /** 57 | * 58 | * @param {admin.database.Reference} root 59 | * @param {string} uid 60 | * @type {Promise} 61 | */ 62 | function match(root, uid) { 63 | let p1uid, p2uid 64 | return root.child('matching').transaction((data) => { 65 | if (data === null) { 66 | console.log(`${uid} waiting for match`) 67 | return { uid: uid } 68 | } 69 | else { 70 | p1uid = data.uid 71 | p2uid = uid 72 | if (p1uid === p2uid) { 73 | console.log(`${p1uid} tried to match with self!`) 74 | return 75 | } 76 | else { 77 | console.log(`matched ${p1uid} with ${p2uid}`) 78 | return {} 79 | } 80 | } 81 | }, 82 | (error, committed, snapshot) => { 83 | if (error) { 84 | throw error 85 | } 86 | else { 87 | return { 88 | committed: committed, 89 | snapshot: snapshot 90 | } 91 | } 92 | }, 93 | false) 94 | .then(result => { 95 | const matching = result.snapshot.val() 96 | if (matching && matching.uid) { 97 | return root.child(`player_states/${uid}`).set({ 98 | matching: true 99 | }) 100 | } 101 | else { 102 | // Create a new game state object and push it under /games 103 | const now = new Date().getTime() 104 | const ref_game = root.child("games").push() 105 | const pr_game = ref_game.set({ 106 | p1uid: p1uid, 107 | p2uid: p2uid, 108 | turn: p1uid, 109 | p1checkin: now, 110 | p2checkin: now 111 | }) 112 | const game_id = ref_game.key 113 | console.log(`starting game ${game_id} with p1uid: ${p1uid}, p2uid: ${p2uid}`) 114 | const pr_state1 = root.child(`player_states/${p1uid}`).set({ 115 | game: game_id, 116 | message: "It's your turn! Make a move!" 117 | }) 118 | const pr_state2 = root.child(`player_states/${p2uid}`).set({ 119 | game: game_id, 120 | message: "Waiting for other player..." 121 | }) 122 | return Promise.all([pr_game, pr_state1, pr_state2]) 123 | } 124 | }) 125 | } 126 | 127 | 128 | /** 129 | * 130 | * @param {admin.database.Reference} root 131 | * @param {string} uid 132 | * @param {object} command 133 | * @type {Promise} 134 | */ 135 | function move(root, uid, command) { 136 | const x = parseInt(command.x) 137 | const y = parseInt(command.y) 138 | if (x < 0 || x > 2 || y < 0 || y > 2) { 139 | throw new Error("That move is out of bounds!") 140 | } 141 | 142 | const ref_self_state = root.child("player_states/" + uid) 143 | let ref_other_state 144 | let ref_game_state 145 | let self_state 146 | 147 | return ref_self_state.once("value") 148 | .then(snap => { 149 | self_state = snap.val() 150 | if (self_state && self_state.game) { 151 | return transactMove(root, uid, self_state.game, x, y) 152 | } 153 | else { 154 | throw new Error("You're not in a game") 155 | } 156 | }) 157 | .catch(reason => { 158 | console.log("Move failed") 159 | console.log(reason) 160 | return ref_self_state.update({ 161 | message: reason.message 162 | }) 163 | }) 164 | } 165 | 166 | 167 | /** 168 | * 169 | * @param {admin.database.Reference} root 170 | * @param {string} uid 171 | * @param {string} game_id 172 | * @param {number} x 173 | * @param {number} y 174 | * @type {Promise} 175 | */ 176 | function transactMove(root, uid, game_id, x, y) { 177 | // Make changes to the game state safely in a transaction 178 | let move_error 179 | root.child(`games/${game_id}`).transaction(game_state => { 180 | console.log("transactMove") 181 | console.log(game_state) 182 | if (game_state == null) { 183 | return null 184 | } 185 | try { 186 | return checkAndApplyMove(root, uid, game_state, x, y) 187 | } 188 | catch (error) { 189 | move_error = error 190 | return 191 | } 192 | }, 193 | (error, committed, snapshot) => { 194 | console.log("transactMove end") 195 | if (error) { 196 | console.log(error) 197 | throw error 198 | } 199 | else if (!committed) { 200 | console.log("Not committed, move error") 201 | console.log(move_error) 202 | return { 203 | message: move_error.message 204 | } 205 | } 206 | else { 207 | console.log("Committed move") 208 | return { 209 | committed: committed, 210 | snapshot: snapshot 211 | } 212 | } 213 | }, 214 | false) 215 | .then(result => { 216 | if (result.committed) { 217 | return notifyPlayers(root, uid, result.snapshot.val()) 218 | } 219 | else { 220 | return root.child(`player_states/${uid}`).update({ 221 | message: result.message 222 | }) 223 | } 224 | }) 225 | } 226 | 227 | 228 | /** 229 | * 230 | * @param {admin.database.Reference} root 231 | * @param {string} uid 232 | * @param {object} game_state 233 | * @param {number} x 234 | * @param {number} y 235 | * @type {object} 236 | */ 237 | function checkAndApplyMove(root, uid, game_state, x, y) { 238 | if (game_state.outcome) { 239 | throw new Error("Game is over!") 240 | } 241 | 242 | const p1uid = game_state.p1uid 243 | const p2uid = game_state.p2uid 244 | 245 | let pl_num 246 | if (uid === p1uid) { 247 | pl_num = 1 248 | } 249 | else if (uid === p2uid) { 250 | pl_num = 2 251 | } 252 | else { 253 | throw new Error("You're not playing this game!") 254 | } 255 | 256 | // Check if it's my turn 257 | const turn = game_state.turn 258 | if (uid !== game_state.turn) { 259 | throw new Error("It's not your turn. Be patient!") 260 | } 261 | 262 | // Build an empty 2d view of game board 263 | const spaces = [] 264 | for (let i = 0; i < 3; i++) { 265 | spaces[i] = [] 266 | for (let j = 0; j < 3; j++) { 267 | spaces[i][j] = undefined 268 | } 269 | } 270 | 271 | if (!game_state.moves) { 272 | game_state.moves = [] 273 | } 274 | 275 | game_state.moves.forEach(move => { 276 | spaces[move.x][move.y] = move.player 277 | }) 278 | 279 | // Check that the space is free 280 | if (spaces[x][y]) { 281 | throw new Error("You can't move there - space already taken!") 282 | } 283 | 284 | // Simulate this move in our 2d space and check for a end condition 285 | spaces[x][y] = pl_num 286 | const end = checkEndgame(spaces) 287 | 288 | // Record the move 289 | game_state.moves.push({ 290 | player: pl_num, 291 | x: x, 292 | y: y 293 | }) 294 | 295 | if (end) { 296 | game_state.turn = null 297 | if (end.winner == 1) { 298 | game_state.outcome = 'win_p1' 299 | game_state.win_moves = end.win_moves 300 | } 301 | else if (end.winner == 2) { 302 | game_state.outcome = 'win_p2' 303 | game_state.win_moves = end.win_moves 304 | } 305 | else if (end.tie) { 306 | game_state.outcome = 'tie' 307 | } 308 | } 309 | else { 310 | // Other player's turn now 311 | game_state.turn = pl_num == 1 ? p2uid : p1uid 312 | } 313 | 314 | return game_state 315 | } 316 | 317 | 318 | /** 319 | * Update each players' individual states given the entire game state. 320 | * 321 | * @param {admin.database.Reference} root 322 | * @param {string} uid 323 | * @param {object} game_state 324 | * @type {Promise} 325 | */ 326 | 327 | function notifyPlayers(root, uid, game_state) { 328 | // Figure out what message should be displayed for each player 329 | let p1_message, p2_message 330 | if (game_state.outcome) { 331 | const outcome = game_state.outcome 332 | if (outcome === 'win_p1') { 333 | p1_message = "You won! Good job!" 334 | p2_message = "They won! Better luck next time!" 335 | } 336 | else if (outcome === 'win_p2') { 337 | p1_message = "They won! Better luck next time!" 338 | p2_message = "You won! Good job!" 339 | } 340 | else if (outcome === 'tie') { 341 | p1_message = p2_message = "It's a tie game!" 342 | } 343 | else if (outcome == 'forfeit_p1') { 344 | p1_message = "Looks like you gave up." 345 | p2_message = "The other player has apparently quit, so you win!" 346 | } 347 | else if (outcome == 'forfeit_p2') { 348 | p1_message = "The other player has apparently quit, so you win!" 349 | p2_message = "Looks like you gave up." 350 | } 351 | } 352 | else { 353 | if (game_state.turn === game_state.p1uid) { 354 | p1_message = "It's your turn! Make a move!" 355 | p2_message = "Waiting for other player..." 356 | } 357 | else { 358 | p1_message = "Waiting for other player..." 359 | p2_message = "It's your turn! Make a move!" 360 | } 361 | } 362 | 363 | if (p1_message && p2_message) { 364 | const update_p1 = { message: p1_message } 365 | const update_p2 = { message: p2_message } 366 | if (game_state.outcome) { 367 | update_p1.game = update_p2.game = null 368 | } 369 | 370 | // Perform the updates 371 | // Construct refs to each players' inividual state locations 372 | // const ref_self_state = root.child(`player_states/${uid}`) 373 | const ref_p1_state = root.child(`player_states/${game_state.p1uid}`) 374 | const ref_p2_state = root.child(`player_states/${game_state.p2uid}`) 375 | const pr_update_p1 = ref_p1_state.update(update_p1) 376 | const pr_update_p2 = ref_p2_state.update(update_p2) 377 | return Promise.all([pr_update_p1, pr_update_p2]) 378 | } 379 | else { 380 | throw new Error("Unexpected case for notifications") 381 | } 382 | } 383 | 384 | 385 | /** 386 | * 387 | * @param {admin.database.Reference} root 388 | * @param {string} uid 389 | * @type {Promise} 390 | */ 391 | function checkin(root, uid) { 392 | const ref_self_state = root.child(`player_states/${uid}`) 393 | return ref_self_state.once("value") 394 | .then(snap => { 395 | const self_state = snap.val() 396 | if (self_state && self_state.game) { 397 | return transactCheckin(root, uid, self_state.game) 398 | } 399 | else { 400 | throw new Error("You're not in a game") 401 | } 402 | }) 403 | } 404 | 405 | 406 | /** 407 | * 408 | * @param {admin.database.Reference} root 409 | * @param {string} uid 410 | * @type {Promise} 411 | */ 412 | function transactCheckin(root, uid, game_id) { 413 | root.child(`games/${game_id}`).transaction(game_state => { 414 | console.log("transactCheckin") 415 | console.log(game_state) 416 | if (game_state == null) { 417 | return null 418 | } 419 | return checkPlayerTimeout(root, uid, game_state) 420 | }, 421 | (error, committed, snapshot) => { 422 | console.log("transactCheckin end") 423 | if (error) { 424 | console.log(error) 425 | throw error 426 | } 427 | else { 428 | return { 429 | committed: committed, 430 | snapshot: snapshot 431 | } 432 | } 433 | }, 434 | false) 435 | .then(result => { 436 | if (result.committed) { 437 | return notifyPlayers(root, uid, result.snapshot.val()) 438 | } 439 | }) 440 | } 441 | 442 | /** 443 | * Look at the game state and figure out if the other player has ghosted, 444 | * forfeiting the game. 445 | * 446 | * @param {admin.database.Reference} root 447 | * @param {string} uid 448 | * @param {object} game_state 449 | * @type {object} 450 | */ 451 | function checkPlayerTimeout(root, uid, game_state) { 452 | if (game_state.outcome) { 453 | throw new Error("Game is over, client shouldn't be checking in") 454 | } 455 | 456 | const p1uid = game_state.p1uid 457 | const p2uid = game_state.p2uid 458 | const p1checkin = game_state.p1checkin 459 | const p2checkin = game_state.p2checkin 460 | const now = new Date().getTime() 461 | 462 | if (p1uid === uid) { 463 | // P1 checkins check that P2 has been also checking in 464 | if (p2checkin + checkin_period * 2 < now) { 465 | game_state.outcome = 'forfeit_p2' 466 | } 467 | game_state.p1checkin = now 468 | } 469 | else if (p2uid === uid) { 470 | // P2 checkins check that P1 has been also checking in 471 | if (p1checkin + checkin_period * 2 < now) { 472 | game_state.outcome = 'forfeit_p1' 473 | } 474 | game_state.p2checkin = now 475 | } 476 | else { 477 | throw new Error(`uid ${uid} is not in this game`) 478 | } 479 | 480 | return game_state 481 | } 482 | 483 | 484 | const wins = [ 485 | // Verticals 486 | [ [0,0], [0,1], [0,2] ], 487 | [ [1,0], [1,1], [1,2] ], 488 | [ [2,0], [2,1], [2,2] ], 489 | // Horizontals 490 | [ [0,0], [1,0], [2,0] ], 491 | [ [0,1], [1,1], [2,1] ], 492 | [ [0,2], [1,2], [2,2] ], 493 | // Diagonals 494 | [ [0,0], [1,1], [2,2] ], 495 | [ [2,0], [1,1], [0,2] ] 496 | ] 497 | 498 | function checkEndgame(spaces) { 499 | for (let i = 0; i < wins.length; i++) { 500 | const win = wins[i] 501 | const m1 = win[0], 502 | m2 = win[1], 503 | m3 = win[2] 504 | const t1 = spaces[m1[0]][m1[1]], 505 | t2 = spaces[m2[0]][m2[1]], 506 | t3 = spaces[m3[0]][m3[1]] 507 | if (t1 && t2 && t3 && t1 == t2 && t1 == t3) { 508 | return { 509 | winner: t1, 510 | win_moves: win 511 | } 512 | } 513 | } 514 | 515 | // If all the spaces are filled, it's a tie 516 | for (let x = 0; x < 3; x++) { 517 | for (let y = 0; y < 3; y++) { 518 | if (spaces[x][y] === undefined) { 519 | // Still empty spaces, game not over 520 | return undefined 521 | } 522 | } 523 | } 524 | 525 | return { tie: true } 526 | } 527 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "dependencies": { 5 | "firebase-admin": "^5.0.0", 6 | "firebase-functions": "^0.5.7" 7 | }, 8 | "private": true 9 | } 10 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Page Not Found 7 | 8 | 23 | 24 | 25 |
26 |

404

27 |

Page Not Found

28 |

The specified file was not found on this website. Please check the URL for mistakes and try again.

29 |

Why am I seeing this?

30 |

This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html file in your project's configured public directory.

31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /public/css/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | body { 18 | font-family: Roboto, sans-serif; 19 | font-size: 16px; 20 | padding: 0; 21 | margin: 0; 22 | } 23 | 24 | .screen { 25 | margin-bottom: 2em; 26 | max-width: 400px; 27 | background-color: #eee; 28 | /*display: none;*/ 29 | } 30 | 31 | .screen button { 32 | width: 200px; 33 | /* height: 40px; */ 34 | border: 0; 35 | padding: 10px; 36 | margin: 10px 0 10px 0; 37 | background-color: rgb(3, 155, 229); 38 | color: white; 39 | font-size: 30px; 40 | text-transform: uppercase; 41 | cursor: pointer; 42 | } 43 | 44 | #login-screen, #options-screen, #matching-screen { 45 | height: 300px; 46 | display: flex; 47 | flex-direction: column; 48 | align-items: center; 49 | justify-content: center; 50 | } 51 | 52 | #game-screen .players { 53 | column-count: 2; 54 | column-gap: 20px; 55 | column-rule-style: solid; 56 | column-rule-color: lightblue; 57 | column-rule-width: 1px; 58 | margin-bottom: 1em; 59 | } 60 | 61 | #game-screen .players { 62 | padding: 10px; 63 | } 64 | 65 | #game-screen .players .profile-pic { 66 | height: 50px; 67 | width: 50px; 68 | min-width: 50px; 69 | min-height: 50px; 70 | } 71 | 72 | #game-screen .players .player { 73 | display: flex; 74 | align-items: flex-start; 75 | } 76 | 77 | #game-screen .players .player .name { 78 | flex-grow: 1; 79 | align-self: center; 80 | } 81 | 82 | #game-screen .players .player1 .profile-pic { 83 | margin-right: 10px; 84 | order: 0; 85 | } 86 | 87 | #game-screen .players .player1 .name { 88 | order: 1; 89 | } 90 | 91 | #game-screen .players .player2 .profile-pic { 92 | order: 1; 93 | margin-left: 10px; 94 | } 95 | 96 | #game-screen .players .player2 .name { 97 | order: 0; 98 | text-align: right; 99 | } 100 | 101 | #game-screen .game-board { 102 | display: table; 103 | margin: 0 auto; 104 | } 105 | 106 | #game-screen .game-board > .row { 107 | column-count: 3; 108 | column-gap: 0px; 109 | } 110 | 111 | #game-screen .game-board > .row > div { 112 | width: 70px; 113 | height: 70px; 114 | border: 1px solid orange; 115 | vertical-align: middle; 116 | text-align: center; 117 | } 118 | 119 | #game-screen .game-board .piece { 120 | position: relative; 121 | top: 10px; /* relative to font and box size */ 122 | font-size: 45px; 123 | } 124 | 125 | #game-screen .game-board .piece:before { 126 | content: " "; 127 | } 128 | 129 | #game-screen .piece.playerX:before { 130 | content: "❌"; 131 | } 132 | 133 | #game-screen .piece.playerO:before { 134 | content: "⭕"; 135 | } 136 | 137 | #game-screen .message { 138 | padding: 1em; 139 | text-align: center; 140 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 29 | 30 | 31 | 34 | 35 | 36 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /public/js/game.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | class Game { 18 | constructor() { 19 | console.log("Game time!") 20 | this.db = firebase.database() 21 | this._reset() 22 | 23 | this.playerPieceClasses = { 24 | "1": "playerX", 25 | "2": "playerO" 26 | } 27 | } 28 | 29 | _reset() { 30 | this.gameId = undefined 31 | this.refPlayerState = undefined 32 | this.refCommands = undefined 33 | } 34 | 35 | onLoad(event) { 36 | this._initUi() 37 | this._initFirebaseAuth() 38 | } 39 | 40 | _initUi() { 41 | this.elLoginScreen = document.getElementById("login-screen") 42 | this.elOptionsScreen = document.getElementById("options-screen") 43 | this.elMatchingScreen = document.getElementById("matching-screen") 44 | this.elGameScreen = document.getElementById("game-screen") 45 | this.allScreens = [ this.elLoginScreen, this.elOptionsScreen, this.elMatchingScreen, this.elGameScreen ] 46 | 47 | this.elMessage = this.elGameScreen.querySelector(".message") 48 | this.elPlayer1 = this.elGameScreen.querySelector(".player1") 49 | this.elPlayer2 = this.elGameScreen.querySelector(".player2") 50 | 51 | this.elSpaces = [] 52 | 53 | this.elCurrentScreen = null 54 | this._showScreen() 55 | 56 | const btn_sign_in = document.getElementById("btn-sign-in") 57 | btn_sign_in.addEventListener("click", event => { 58 | var provider = new firebase.auth.GoogleAuthProvider() 59 | provider.addScope("profile") 60 | firebase.auth().signInWithRedirect(provider) 61 | firebase.auth().getRedirectResult() 62 | .then(result => { 63 | console.log("auth success") 64 | console.log(result) 65 | // This gives you a Google Access Token. You can use it to access the Google API. 66 | var token = result.credential.accessToken 67 | // The signed-in user info. 68 | var user = result.user 69 | // ... 70 | }) 71 | .catch(error => { 72 | console.log("auth error") 73 | // Handle Errors here. 74 | var errorCode = error.code; 75 | var errorMessage = error.message; 76 | // The email of the user's account used. 77 | var email = error.email; 78 | // The firebase.auth.AuthCredential type that was used. 79 | var credential = error.credential; 80 | // ... 81 | }) 82 | }) 83 | 84 | const btn_sign_out = document.getElementById("btn-sign-out") 85 | btn_sign_out.addEventListener("click", event => { 86 | firebase.auth().signOut() 87 | }) 88 | 89 | const btn_play = document.getElementById("btn-play") 90 | btn_play.addEventListener("click", event => { 91 | this._sendCommand({ command: "match" }) 92 | }) 93 | 94 | const el_game_board = this.elGameScreen.querySelector(".game-board") 95 | for (let x = 0; x < 3; x++) { 96 | this.elSpaces[x] = [] 97 | for (let y = 0; y < 3; y++) { 98 | const sp = el_game_board.querySelector(`.sp-x${x}-y${y}`) 99 | sp.addEventListener("click", event => { 100 | console.log(`click x=${x} y=${y}`) 101 | // if it's my turn... 102 | this._makeMove(x, y) 103 | }) 104 | this.elSpaces[x][y] = sp 105 | } 106 | } 107 | } 108 | 109 | _sendCommand(command) { 110 | if (this.sendingCommand) { 111 | console.log("Throttling command") 112 | return 113 | } 114 | this.sendingCommand = true 115 | this.refCommands.push(command) 116 | .then(result => { 117 | this.sendingCommand = false 118 | }) 119 | } 120 | 121 | _showScreen(screen) { 122 | this.allScreens.forEach(sc => { 123 | if (sc === screen) { 124 | sc.style.display = "" 125 | this.elCurrentScreen = screen 126 | } 127 | else { 128 | sc.style.display = "none" 129 | } 130 | }) 131 | } 132 | 133 | _initFirebaseAuth() { 134 | firebase.auth().onAuthStateChanged(this._onAuthStateChanged.bind(this)) 135 | } 136 | 137 | _onAuthStateChanged(user) { 138 | if (user) { 139 | console.log(`signed in ${user.displayName}`) 140 | console.log(user) 141 | this._onSignIn(user) 142 | if (this.elCurrentScreen === this.elLoginScreen || !this.elCurrentScreen) { 143 | this._showScreen(this.elOptionsScreen) 144 | } 145 | } 146 | else { 147 | console.log("signed out") 148 | this._onSignOut() 149 | this._showScreen(this.elLoginScreen) 150 | } 151 | } 152 | 153 | _onSignIn(user) { 154 | this.user = user 155 | this.refPlayerState = this.db.ref(`/player_states/${user.uid}`) 156 | this.refCommands = this.db.ref(`/commands/${user.uid}`) 157 | this.onPlayerStateChanged = this.refPlayerState.on("value", this._onPlayerStateChanged.bind(this)) 158 | this.db.ref(`/players/${user.uid}`).update({ 159 | displayName: user.displayName, 160 | photoUrl: user.photoURL 161 | }) 162 | } 163 | 164 | _onSignOut() { 165 | if (this.refPlayerState) { 166 | this.refPlayerState.off("value", this.onPlayerStateChanged) 167 | this.refPlayerState = undefined 168 | } 169 | this.refCommands = undefined 170 | this.user = null 171 | if (this.checkin) { 172 | clearInterval(this.checkin) 173 | this.checkin = undefined 174 | } 175 | this._reset() 176 | } 177 | 178 | _onPlayerStateChanged(snap) { 179 | console.log("onPlayerStateChanged") 180 | const state = snap.val() || {} 181 | console.log(state) 182 | if (state.matching) { 183 | this._showScreen(this.elMatchingScreen) 184 | } 185 | else if (state.game) { 186 | this._showScreen(this.elGameScreen) 187 | if (!this.refGameState) { 188 | this._enterGame(state.game) 189 | } 190 | } 191 | else { 192 | // Stay on the game screen if the game is over 193 | if (this.elCurrentScreen !== this.elGameScreen) { 194 | this._showScreen(this.elOptionsScreen) 195 | } 196 | } 197 | if (state.message) { 198 | this.elMessage.textContent = state.message 199 | } 200 | } 201 | 202 | _enterGame(game_id) { 203 | console.log("enterGame " + game_id) 204 | this.gameId = game_id; 205 | this.refGameState = this.db.ref(`/games/${game_id}`) 206 | this.onGameStateChanged = this.refGameState.on("value", this._onGameStateChanged.bind(this)) 207 | this.checkin = setInterval(this._checkin.bind(this), 10000) 208 | console.log(this.refGameState) 209 | } 210 | 211 | _checkin() { 212 | console.log("checkin") 213 | this._sendCommand({ command: "checkin" }) 214 | } 215 | 216 | _exitGame() { 217 | console.log("exitGame " + this.gameId) 218 | if (this.gameId) { 219 | this.refGameState.off("value", this.onGameStateChanged) 220 | } 221 | this.gameId = undefined 222 | this.refGameState = undefined 223 | this.onGameStateChanged = undefined 224 | if (this.checkin) { 225 | clearInterval(this.checkin) 226 | this.checkin = undefined 227 | } 228 | } 229 | 230 | _updatePlayerUi(el, snap) { 231 | const player = snap.val() 232 | console.log(player) 233 | const name = player.displayName === "" ? "???" : player.displayName 234 | el.querySelector(".name").textContent = name 235 | el.querySelector(".profile-pic").src = player.photoUrl + "?sz=100" 236 | } 237 | 238 | _onGameStateChanged(snap) { 239 | console.log("onGameStateChanged") 240 | const state = snap.val() 241 | console.log(state) 242 | if (!state) { 243 | this._showScreen(this.elOptionsScreen) 244 | } 245 | 246 | this.db.ref(`/players/${state.p1uid}`).once("value") 247 | .then(this._updatePlayerUi.bind(this, this.elPlayer1)) 248 | this.db.ref(`/players/${state.p2uid}`).once("value") 249 | .then(this._updatePlayerUi.bind(this, this.elPlayer2)) 250 | 251 | // Initialize the game board display spaces 252 | for (let x = 0; x < 3; x++) { 253 | for (let y = 0; y < 3; y++) { 254 | this.elSpaces[x][y].firstElementChild.className = "piece" 255 | } 256 | } 257 | 258 | // Apply all the moves logged so far in the game 259 | snap.child("moves").forEach(snap => { 260 | const move = snap.val() 261 | const space = this.elSpaces[move.x][move.y] 262 | space.firstElementChild.classList.add(this.playerPieceClasses[move.player]) 263 | }) 264 | 265 | // Game is over 266 | if (state.outcome) { 267 | this._exitGame() 268 | return 269 | } 270 | } 271 | 272 | _makeMove(x, y) { 273 | console.log("Making my move...") 274 | this._sendCommand({ 275 | command: "move", 276 | x: x, 277 | y: y 278 | }) 279 | } 280 | } 281 | 282 | const app = new Game() 283 | window.addEventListener("load", app.onLoad.bind(app)) 284 | --------------------------------------------------------------------------------