├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .tool-versions ├── LICENSE ├── Procfile ├── README.md ├── TUTORIAL.md ├── assets ├── 96634642-b6ac0d00-1312-11eb-9ebb-0b7cd9122fc3.png ├── 96634661-bad82a80-1312-11eb-9e61-661e42eaf485.png ├── 96634667-bd3a8480-1312-11eb-80ea-5a72ef329692.png ├── 96635120-608b9980-1313-11eb-86b7-059ff18e7d1e.png ├── a-simplified-architecture-for-multiplayer-quiz.png └── ably-channels-for-multiplayer-quiz-implementation.png ├── package-lock.json ├── package.json ├── quiz-questions.json ├── quiz-room-server.js ├── realtime-quiz ├── .gitignore ├── README.md ├── babel.config.js ├── dist │ ├── bg-shapes.png │ ├── css │ │ └── app.a1d562e2.css │ ├── favicon.ico │ ├── index.html │ └── js │ │ ├── app.db17cef0.js │ │ ├── app.db17cef0.js.map │ │ ├── chunk-vendors.fe24a5fe.js │ │ └── chunk-vendors.fe24a5fe.js.map ├── package-lock.json ├── package.json ├── public │ ├── bg-shapes.png │ ├── favicon.ico │ └── index.html └── src │ ├── App.vue │ ├── assets │ ├── bg-shapes.png │ └── logo.png │ ├── components │ ├── common │ │ ├── Answer.vue │ │ ├── Leaderboard.vue │ │ ├── LiveStats.vue │ │ ├── OnlinePlayers.vue │ │ └── Question.vue │ ├── host │ │ ├── AdminPanel.vue │ │ ├── CreateQuizRoom.vue │ │ └── HostHome.vue │ └── player │ │ └── PlayerHome.vue │ ├── main.js │ └── routes.js └── server.js /.env.example: -------------------------------------------------------------------------------- 1 | # save as .env 2 | ABLY_API_KEY= 3 | PORT=5000 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | realtime-quiz/dist/* 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:vue/essential" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 12, 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "vue" 17 | ], 18 | "rules": {} 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .vscode 4 | .DS_Store 5 | vetur.config.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.7.0 2 | ruby 3.0.0 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A scalable, full-stack live quiz framework built with VueJS and NodeJS 2 | 3 | This repository contains a scalable framework for building a real-time quiz app, which can double up as a test-taking app in an e-learning scenario or be used for a Pub Quiz Friday with your work mates. 4 | 5 | Host home 6 | 7 | It provides full control of the sequence of events to the host and is able to simultaneously run any number of quiz rooms, with any number of people in each of those (using Node JS worker threads!). 8 | 9 | Custom quiz option 10 | 11 | The hosts have an option to add their own questions, optionally with images, via Google Sheets. 12 | 13 | Ongoing quiz screen 14 | 15 | Host admin controls 16 | 17 | The real-time messaging is powered by [Ably’s real-time infrastructure](https://www.ably.com), meaning, it can have enterprise-level scalability without needing to change anything in the code. Ideally, you’d take this open-sourced framework as a starting point and customize it to make it your own. 18 | 19 | This framework is built on top of the [multiplayer games networking framework](https://github.com/Srushtika/multiplayer-games-scalable-networking-framework) that allows continuous streaming of data between various players and the game server. This framework is a bit different in that it allows for a more on-demand progression of the app by giving adequate controls of the app flow to the host. 20 | 21 | You can check out a [blog article](https://www.ably.com/blog/a-scalable-realtime-quiz-framework-to-build-edtech-apps/) I wrote, to learn more about the uses of this framework. 22 | 23 | #### Check out the [functional demo](https://quiz.ably.dev/) for this realtime quiz framework. 24 | 25 | --- 26 | 27 | ### The tech stack 28 | 29 | ##### Frameworks/ Languages 30 | 31 | - [Vue JS](https://vuejs.org/) 32 | - [Node JS](https://nodejs.org/en/) 33 | 34 | ##### Libraries 35 | 36 | - [Ably Realtime](https://ably.com/) 37 | - [Express](https://expressjs.com/) 38 | - [Vue Router](https://router.vuejs.org/) 39 | - [Axios](https://www.npmjs.com/package/axios) 40 | - [G Sheets API](https://www.npmjs.com/package/g-sheets-api) 41 | - [Bootstrap](https://getbootstrap.com/) 42 | - [DotEnv](https://www.npmjs.com/package/dotenv) 43 | 44 | ### How to run this framework locally 45 | 46 | 1. Clone this repository 47 | 48 | ```sh 49 | git clone https://github.com/ably-labs/realtime-quiz-framework.git 50 | ``` 51 | 52 | 2. Change directory to the project folder and Install dependencies 53 | 54 | ```sh 55 | npm install 56 | 57 | cd realtime-quiz/ 58 | npm install 59 | 60 | cd .. 61 | ``` 62 | 63 | **TIP:** you can do exactly the same thing with one command 64 | by concatenating the commands with `&&`. 65 | 66 | Here we use the shorthand `npm i` instead of `npm install`. 67 | 68 | ``` 69 | npm i && cd realtime-quiz/ && npm i && cd .. 70 | ``` 71 | 72 | 3. Create a free account with [Ably Realtime](https://www.ably.com/) to get your Ably API KEY. Add a new file called `.env` and add the following, or use the example `.env.example` and save that as `.env`.(Remember to replace the placeholder with your own API Key. You can get your Ably API key from your Ably dashboard): 73 | 74 | ``` 75 | ABLY_API_KEY= 76 | PORT=5000 77 | ``` 78 | 79 | 4. Run the server 80 | 81 | ```sh 82 | node server.js 83 | ``` 84 | 85 | 5. Open the app in your browser at [http://localhost:5000](http://localhost:5000). Choose the quiz type and create a quiz room. 86 | 87 | 6. Copy the shareable link and open it in a separate browser window. This is best experienced in mobile view. Open multiple of these to simulate multiple players if you like. 88 | 89 | 7. Start the quiz when you are ready and have the players answer the questions as they appear. 90 | 91 | Voila! Your live quiz framework is up and running. Customize this framework and make it your own. Feel free to share your quiz app with me on [Twitter](https://twitter.com/Srushtika), I'll be happy to give it a shoutout! 92 | 93 | --- 94 | 95 | ### What’s in which file? 96 | 97 | #### Server-side files 98 | 99 | 1. `server.js` 100 | 101 | This file has the main server thread. It performs three functions: 102 | 103 | - Serve the front-end VueJS app using Express. 104 | 105 | - Authenticate front-end clients with the Ably Realtime service using [Token Auth strategy](https://www.ably.com/documentation/core-features/authentication#token-authentication). 106 | 107 | - Create and manage Node JS worker threads when a host requests to create a quiz room. 108 | 109 | 2. `quiz-room-server.js` 110 | 111 | This file represents a Node JS worker thread. A new instance of this file will run for every quiz room created. 112 | 113 | After a live quiz session is finished, the relevant worker thread is killed. When a new player joins or leaves the worker thread communicates with the parent thread (i.e. main thread aka server.js) and lets it know the number of players (among other things). 114 | 115 | 3. `quiz-default-questions.js` 116 | 117 | This file exports a JSON array with a set of quiz questions with options and correct answers. This will be used by the `quiz-room-server` when a host chooses the "Host a randomly chosen quiz" option. 118 | 119 | #### Client-side files 120 | 121 | The client-side is written in VueJS with the following file structure (Note: Only the relevant files are listed here) 122 | 123 | ``` 124 | realtime-quiz 125 | |___dist 126 | |___public 127 | | |___index.html 128 | |___src 129 | |___main.js 130 | |___routes.js 131 | |___App.vue 132 | |___components 133 | | 134 | |___common 135 | | |___Answer.vue 136 | | |___OnlinePlayers.vue 137 | | |___Question.vue 138 | | 139 | |___host 140 | | |___AdminPanel.vue 141 | | |___CreateQuizRoom.vue 142 | | |___HostHome.vue 143 | | |___Leaderboard.vue 144 | | |___LiveStats.vue 145 | | 146 | |___player 147 | |___PlayerHome.vue 148 | ``` 149 | 150 | 1. `dist` folder 151 | 152 | The dist folder contains the built Vue app that is auto-generated when you run the `npm run build` command after finishing your work on the Vue app. The `index.html` file in this folder is what’s served by our express server (for all routes, as routing is handled in the front-end using `vue-router`) 153 | 154 | 2. `public` folder 155 | 156 | The public folder contains the `index.html` file inside which all the components will be rendered based on the app logic. 157 | 158 | 3. `src` folder, `main.js` and `routes.js` 159 | 160 | The src folder contains our app files starting with the main.js file which instantiates a new Vue instance with `App.vue` being the top-level component. We also instantiate the Vue Router instance in this file. The different components to be rendered based on the routes are listed in the routes.js file. 161 | 162 | 4. `App.vue` 163 | 164 | As this is the top-level component for our Vue app, it instantiates the Ably library using the Token Authentication strategy and passes on the `realtime` instance to its child components so they can use it as they need. 165 | 166 | 5. `components` folder 167 | This folder contains all the child components for `App.vue`. They are placed in different folders for a better context. 168 | 169 | The `common` folder has components that are common to the host of the quiz app and the players. The `host` folder has components that are visible to the host only. The `player` folder has components that are visible to the player only. 170 | 171 | 6. `common/OnlinePlayers.vue` 172 | 173 | This component holds the logic and UI to show a staging area with a list of players who are online. New players are added to this list as they join in realtime. For every player, a thumbnail of their randomly chosen unique colored avatar along with a name as a tagline underneath appears. 174 | 175 | 7. `common/Question.vue` 176 | 177 | This component holds the logic and UI to show a card with the question, optionally an image, and four options. 178 | 179 | The players have buttons to choose one of the options whereas the host has the options listed as non-clickable divs as they won’t be answering the questions. 180 | 181 | 8. `common/Answer.vue` 182 | 183 | This component holds the logic and UI to show a card with the answer. For the player, this component appears standalone and also indicates if the option they chose was correct or not. For the host, this component replaces the four options in the question. 184 | 185 | 9. `host/HostHome.vue` 186 | 187 | This is the main component that is shown when anyone lands on the app. By default, all the hosts land on this page and they get a shareable URL to invite their players after they have chosen the type of quiz they'd like to host. This component allows the host to choose the quiz type and provides a way to upload their own questions if they need. 188 | 189 | 10. `host/CreateQuizRoom.vue` 190 | 191 | This component holds the logic and UI to allow the host to create a new quiz room and get a shareable URL to invite players to that quiz room. 192 | 193 | 11. `host/Leaderboard.vue` 194 | 195 | This component is visible to the host after every question. It shows the top five scorers in the quiz until that point. If the quiz has ended, the same component displays a full leaderboard with all the participants. 196 | 197 | 12. `host/AdminPanel.vue` 198 | 199 | This component is visible to the host after every question, giving them options to show the next question when they are ready, or end the quiz midway through. 200 | 201 | 13. `host/LiveStats.vue` 202 | 203 | This component is visible to the host at the time of a question being displayed. It shows live stats on how many players are still online and out of those, how many have already selected an option for that question. This component can be extended to include the names of the players who have answered, as they do, or any other live stats. 204 | 205 | 14. `player/Playerhome.vue` 206 | 207 | This component is visible to the player. This is the first page they see when they follow a link shared by their host. It allows them to add their nickname and enter the quiz room created by their host. They’ll be waiting along with other players until the host decides to start the quiz. 208 | 209 | If you want to learn more about the source code, you should check out the [TUTORIAL.md](https://github.com/Srushtika/realtime-quiz-framework/blob/main/TUTORIAL.md) for a more thorough breakdown. 210 | 211 | ## Core concepts 212 | 213 | ### The communication architecture 214 | 215 | Before we look at the architecture and design of the app, we need to understand a few concepts based on which this app is built 216 | 217 | #### App architecture 218 | 219 | This app is designed in a way where the server can be considered as the single source of truth and is responsible to maintain the latest quiz state at all times. 220 | 221 | All the players send their answers to the server, which in turn collates them together, computes the leaderboard, and sends this info to the host of the quiz. The server is also responsible for publishing new questions, the timer in between questions, and the correct answer after the time has elapsed or all the players have answered. 222 | 223 | The client-side script will use this information from the server and render various components accordingly, ensuring all the players are fully in-sync. 224 | 225 | ![](./assets/a-simplified-architecture-for-multiplayer-quiz.png) 226 | 227 | #### The WebSockets protocol 228 | 229 | The [WebSockets protocol](https://www.ably.com/concepts/websockets), unlike HTTP, is a stateful communications protocol that works over TCP. The communication initially starts off as an HTTP handshake, but if both the communicating parties agree to continue over WebSockets then the connection is elevated; giving rise to a full-duplex, persistent connection. This means the connection remains open for the duration that the application is in use. This gives the server a way to initiate any communication and send data to pre-subscribed clients, so they don’t have to keep sending requests inquiring about the availability of new data. Which is exactly what we need in our quiz! 230 | 231 | This project uses [Ably Realtime](https://www.ably.com) to implement WebSocket based realtime messaging between the server, host and the players. Ably, by default, deals with scalability, protocol interoperability, reliable message ordering, guaranteed message delivery, historical message retention and authentication, so we don't have to. This communication follows the [Pub/Sub messaging pattern](https://www.ably.com/concepts/pub-sub). 232 | 233 | ##### Publish/Subscribe messaging pattern 234 | 235 | [Pub/Sub](https://www.ably.com/concepts/pub-sub) messaging allows various front-end or back-end clients to publish some data and/or subscribe to some data. For any active subscriptions, these clients will receive asynchronous event callbacks when a new message is published. 236 | 237 | ##### Channels 238 | 239 | In any realtime app, there's a lot of moving data involved. [Channels](https://www.ably.com/documentation/core-features/channels) help us group this data logically and let us implement subscriptions per channel. This allowing us to implement the custom callback logic for different scenarios. In the diagram above, each color would represent a channel. 240 | 241 | ##### Presence 242 | 243 | [Presence](https://www.ably.com/documentation/core-features/presence) is an Ably feature using which you can track the connection status of various clients on a channel. In essence, you can see who has just come online and who has left using each client's unique `clientId` 244 | 245 | #### Sequence of events with Node JS worker threads (For an example quiz) 246 | 247 | ![](./assets/ably-channels-for-multiplayer-quiz-implementation.png) 248 | 249 | --- 250 | 251 | ## Load tests and limits 252 | 253 | - All of Ably's messaging limits, broken down by package can be found in a [support article](https://support.ably.com/support/solutions/articles/3000053845-do-you-have-any-connection-message-rate-or-other-limits-on-accounts-). 254 | 255 | - We are currently performing load and performance tests on this framework and will update this guide with that info when it's available. If this is important to you, please [leave a message to me directly on Twitter](https://www.twitter.com/Srushtika) or reach out to Ably's support at [support@ably.com](mailto:support@ably.com) 256 | -------------------------------------------------------------------------------- /TUTORIAL.md: -------------------------------------------------------------------------------- 1 | ## Documentation and understanding the code 2 | 3 | This article is an extension of the info present in the `README.md` file and focuses on the explanation of various code snippets from this application. This article assumes that you've already read through the `README.md`. 4 | 5 | ### Creating Node JS worker threads 6 | 7 | This kit uses [Node JS worker threads](https://nodejs.org/api/worker_threads.html) to create new quiz rooms so various people can host their quizzes independently. 8 | 9 | To create and use Node JS worker threads, from the main thread, you'll need to require the worker_threads library: 10 | 11 | ```js 12 | const { 13 | Worker, 14 | isMainThread, 15 | parentPort, 16 | workerData, 17 | threadId, 18 | MessageChannel 19 | } = require('worker_threads'); 20 | ``` 21 | 22 | and instance a new worker and pass it two parameters: 23 | 24 | a) path to the worker file 25 | 26 | b) data as a JSON object 27 | 28 | ```js 29 | const worker = new Worker('./quiz-room-server.js', { 30 | workerData: { 31 | hostNickname: hostNickname, 32 | hostRoomCode: hostRoomCode, 33 | hostClientId: hostClientId 34 | } 35 | }); 36 | ``` 37 | 38 | In the worker file, you'll need to require the same library `worker_threads`. You'll have access to the `workerData` object directly. 39 | 40 | For example, in the worker thread, you can access the host nickname with `workerData.hostNickname`. 41 | 42 | Communication between worker and main threads 43 | The worker thread can publish data to the main thread as follows: 44 | 45 | ```js 46 | parentPort.postMessage({ 47 | roomCode: roomCode, 48 | totalPlayers: totalPlayers, 49 | didQuizStart: didQuizStart 50 | }); 51 | ``` 52 | 53 | In this kit, the worker thread communicates with the main thread on four occasions: 54 | 55 | - When a new player joins the quiz room. 56 | - When an existing player leaves the quiz room. 57 | - When the host has requested to start the quiz. 58 | - When the quiz has finished and the worker thread is going to be killed. 59 | 60 | This information is used by the main server thread to maintain a list of active worker threads, along with the number of players in each. 61 | 62 | #### Connecting to Ably 63 | 64 | In order to use this kit, you will need an Ably API key. If you are not already signed up, you can [sign up now for a free Ably account](https://www.ably.com/signup). Once you have an Ably account: 65 | 66 | - Log into your app dashboard 67 | - Under **Your apps**, click on **Manage app** for any app you wish to use for this tutorial, or create a new one with the **Create New App** button 68 | - Click on the **API Keys** tab 69 | - Copy the secret **API Key** value from your Root key. 70 | 71 | The server-side scripts connect to Ably using [Basic Authentication](https://www.ably.com/documentation/core-features/authentication#basic-authentication), i.e. by using the API Key directly as shown below: 72 | 73 | ```js 74 | const envConfig = require('dotenv').config(); 75 | const { ABLY_API_KEY } = envConfig.parsed; 76 | 77 | const realtime = new Ably.Realtime({ 78 | key: ABLY_API_KEY, 79 | echoMessages: false 80 | }); 81 | ``` 82 | 83 | Note: Setting the `echoMessages` false prevents the server from receiving its own messages. 84 | 85 | The main server thread uses Express to listen to HTTP requests. It has an `/auth` endpoint that is used by the client-side scripts to authenticate with Ably using tokens. This is a recommended strategy as placing your secret API Key in a front-end script exposes it to potential misuse. The client-side scripts connect to Ably using [Token Authentication](https://www.ably.com/documentation/core-features/authentication#token-authentication) as shown below: 86 | 87 | ```js 88 | const realtime = new Ably.Realtime({ 89 | authUrl: '/auth' 90 | }); 91 | ``` 92 | 93 | #### Ably channel names used by this project 94 | 95 | 1. `main-quiz-thread` - Used by the main server thread to listen for host entries and leaves via presence. This info is used to be able to create new Node JS worker threads for new quiz rooms. 96 | 97 | 2. `:host` - Host channel for this quiz room. It'll be used by the host and the server to communicate host-only events. 98 | 99 | 3. `:primary` - Main channel for a particular quiz room. It'll be used by players to enter or leave presence on the quiz room and by the worker thread to publish and receive questions and answers. 100 | 101 | 4. `:player-ch-` - Unique channel for every player, which is used to publish their answers to the worker thread. The worker thread is subscribed to one such channel per player. 102 | 103 | You can also add any other channels that you may need in your quiz. 104 | 105 | > Note: Due to the fact that the above channel names exist in a unique [channel namespace](https://support.ably.com/support/solutions/articles/3000030058-what-is-a-channel-namespace-and-how-can-i-use-them-) identified by the unique room code (separated from channel names with a ':' i.e. colon), you can guarantee that one quiz room's data never creeps into the other. 106 | 107 | ## Understanding the code 108 | 109 | Assuming you’ve seen the working of the app and understand the file structure explained in the README, let’s start by understanding the server-side code. 110 | 111 | ### Server-side code 112 | 113 | #### 1. The `server.js` file 114 | 115 | In this file, after requiring the necessary NPM libraries, we start with instantiating the Ably library. 116 | 117 | ```js 118 | const realtime = new Ably.Realtime({ 119 | key: ABLY_API_KEY, 120 | echoMessages: false 121 | }); 122 | ``` 123 | 124 | Ably.Realtime takes the client options JSON object as an argument and we have the Ably API Key ([Basic auth](https://www.ably.com/documentation/core-features/authentication#basic-authentication)) and `echoMessages` which when set to false prevents the client from receiving their own messages i.e if they are publishing to a channel that they are subscribed to. 125 | 126 | Next, we set up a few routes, and have express handle them using `app.get(‘/route’, callback)`. We have the following routes: 127 | 128 | 1. `/` - this is the default route of the application, so we’ll have the server send the `index.html` file from the `dist` folder (which is a result of building our Vue project). 129 | 130 | 2. `/play` - this is the route used by players of the quiz and is helpful to differentiate players from hosts. Since we are using the `vue-router` on the front-end, which will handle routing locally, we can have our server serve the same `index.html` file from the `dist` folder as before. 131 | 132 | 3. `/auth` - this is used by the front-end clients to authenticate with ably using token authentication (unlike the server which is using basic authentication). It’s never recommended to use API Keys directly on the front-end. 133 | 134 | 4. `/checkRoomStatus` - this is the route used by the front-end app served to a player. The player app will send a request to this endpoint to check if a given quiz room still exists and is ok to take in players. The server stores information of all the available quiz rooms locally, so it can check it and respond accordingly. Based on the response, the front-end app will either allow the players to enter the room or let them know it’s not possible. 135 | 136 | Other than serving the files and data, the `server.js` file also creates new worker threads for every new quiz room requested. 137 | 138 | When the server has successfully established a connection with Ably, it attaches to the `main-quiz-thread` channel and subscribes to presence on that channel. 139 | 140 | ```js 141 | globalQuizChannel = realtime.channels.get(globalQuizChName); 142 | globalQuizChannel.presence.subscribe('enter', (player) => { } 143 | ``` 144 | 145 | We’ll see in the front end app the point at which they enter the presence set, but when the host does enter the presence set, the callback to `channel.presence.subscribe(‘enter’, callback)` will be triggered. 146 | 147 | Our server will take this as a cue to create a new quiz room (aka NodeJS worker thread). This is done in the `generateNewQuizRoom()` method. The main thing to notice in this method is the instantiation of the worker thread: 148 | 149 | ```js 150 | const worker = new Worker('./quiz-room-server.js', { 151 | workerData: { 152 | hostNickname: hostNickname, 153 | hostRoomCode: hostRoomCode, 154 | hostClientId: hostClientId 155 | } 156 | }); 157 | ``` 158 | 159 | We specify a path to the file which has the code to run in the worker thread. This can be part of the same file too but it’s just cleaner to separate them out. We then send some initial data called worker data, so the worker thread has some context and get started and working with that initial data. 160 | 161 | In the same `generateNewQuizRoom()` method, we also set up listeners to various events such as `message`, `exit`, and `error` on this worker and handle them accordingly. 162 | 163 | #### 2. The `quiz-room-server.js` file 164 | 165 | This file represents the logic for an individual quiz room. It communicates with the parent thread only (i.e. `server.js`). It does not communicate or in any way share data with other worker threads. 166 | 167 | Each quiz room is identified by a unique room code, which is generated on the front end host app before they enter the main thread. This unique code eventually comes to the `quiz-room-server.js`, so it can make use of it to attach to unique [channel namespaces](https://support.ably.com/support/solutions/articles/3000030058-what-is-a-channel-namespace-and-how-can-i-use-them-) in Ably, identified partly by its unique code. 168 | 169 | So we’ll start by instantiating Ably for the worker thread in exactly the same way as we did before with `server.js`: 170 | 171 | ```js 172 | const realtime = new Ably.Realtime({ 173 | key: ABLY_API_KEY, 174 | echoMessages: false 175 | }); 176 | ``` 177 | 178 | Once the connection is successfully established, we’ll attach to the host channel and the quiz room channel. The host channel will be used to send and receive host-only events (aka admin level controls). The quiz room channel will be more generally used to communicate with the host and the players, in terms of the quiz questions, timers, answers, etc. 179 | 180 | We’ll understand the `subscribeToHostEvents()` in just a bit but after calling that method, we subscribe to presence enter and leave events on the quiz room channel. We’ll use it to keep a track of the online players (and the host) along with their unique client Ids and other attributes like player score, etc. 181 | 182 | After this is done, we’ll publish an event called `thread-ready`, so the host can start inviting other players to enter this quiz room. 183 | 184 | ```js 185 | realtime.connection.once('connected', () => { 186 | hostAdminCh = realtime.channels.get(hostAdminChName); 187 | quizRoomChannel = realtime.channels.get(quizRoomChName); 188 | 189 | subscribeToHostEvents(); 190 | 191 | quizRoomChannel.presence.subscribe('enter', handleNewPlayerEntered); 192 | 193 | quizRoomChannel.presence.subscribe('leave', handleExistingPlayerLeft); 194 | 195 | quizRoomChannel.publish('thread-ready', { start: true }); 196 | }); 197 | ``` 198 | 199 | Let's now understand all the methods in this file one by one. 200 | 201 | ##### The `subscribeToHostEvents()` method 202 | 203 | ```js 204 | function subscribeToHostEvents() { 205 | hostAdminCh.subscribe('start-quiz', async () => { 206 | didQuizStart = true; 207 | parentPort.postMessage({ 208 | roomCode, 209 | didQuizStart 210 | }); 211 | await publishTimer('start-quiz-timer', START_TIMER_SEC); 212 | publishQuestion(0, false); 213 | }); 214 | 215 | hostAdminCh.subscribe('quiz-questions', (msg) => { 216 | for (let i = 0; i < msg.data.questions.length; i++) { 217 | let item = msg.data.questions[i]; 218 | let newQuestionObject = { 219 | questionNumber: parseInt(item['question number']), 220 | showImg: item['image link'].substr(0, 4) === 'http' ? true : false, 221 | question: item.question, 222 | choices: [ 223 | item['option 1'], 224 | item['option 2'], 225 | item['option 3'], 226 | item['option 4'] 227 | ], 228 | correct: parseInt(item['correct answer option number']) - 1, 229 | pic: item['image link'] 230 | }; 231 | customQuestions.push(newQuestionObject); 232 | } 233 | }); 234 | 235 | hostAdminCh.subscribe('next-question', (msg) => { 236 | let prevQIndex = msg.data.prevQIndex; 237 | let newQIndex = prevQIndex + 1; 238 | let lastQIndex = questions.length - 1; 239 | if (newQIndex < lastQIndex) { 240 | publishQuestion(newQIndex, false); 241 | } else if (newQIndex === lastQIndex) { 242 | publishQuestion(newQIndex, true); 243 | } 244 | }); 245 | 246 | hostAdminCh.subscribe('end-quiz-now', () => { 247 | forceQuizEnd(); 248 | }); 249 | } 250 | ``` 251 | 252 | We subscribe to four host-only events on the host channel: 253 | 254 | - `start-quiz` - Published by the host when they are ready to start the quiz. 255 | - `quiz-questions` - Published by the host with a list of questions when they’ve chosen the custom questions option. 256 | - `next-question` - Published by the host when they’d like to show the next question. 257 | - `end-quiz-now` - Published by the host if they’d like to end the quiz mid-way through. 258 | 259 | ##### The `handleNewPlayerEntered()` method 260 | 261 | ```js 262 | function handleNewPlayerEntered(player) { 263 | console.log(player.clientId + 'player entered quiz room'); 264 | const newPlayerId = player.clientId; 265 | totalPlayers++; 266 | parentPort.postMessage({ 267 | roomCode: roomCode, 268 | totalPlayers: totalPlayers, 269 | didQuizStart: didQuizStart 270 | }); 271 | 272 | let newPlayerState = { 273 | id: newPlayerId, 274 | nickname: player.data.nickname, 275 | avatarColor: player.data.avatarColor, 276 | isHost: player.data.isHost, 277 | score: 0 278 | }; 279 | 280 | if (player.data.isHost) { 281 | let quizType = player.data.quizType; 282 | quizType === 'CustomQuiz' 283 | ? (questions = customQuestions) 284 | : (questions = randomQuestions); 285 | } else { 286 | playerChannels[newPlayerId] = realtime.channels.get( 287 | `${roomCode}:player-ch-${player.clientId}` 288 | ); 289 | subscribeToPlayerChannel(playerChannels[newPlayerId], newPlayerId); 290 | } 291 | globalPlayersState[newPlayerId] = newPlayerState; 292 | quizRoomChannel.publish('new-player', { 293 | newPlayerState 294 | }); 295 | } 296 | ``` 297 | 298 | When a new player enters, we update the `totalPlayers` count and let the parent thread (`server.js`) know. This is needed so the `server.js` can manage all the available worker threads and their states. This is also needed to allow or reject new players wanting to join a quiz room. 299 | 300 | ##### The `handleExistingPlayerLeft()` method: 301 | 302 | ```js 303 | function handleExistingPlayerLeft(player) { 304 | console.log('leaving player', player.clientId); 305 | const leavingPlayerId = player.clientId; 306 | totalPlayers--; 307 | parentPort.postMessage({ 308 | roomCode: roomCode, 309 | totalPlayers: totalPlayers 310 | }); 311 | delete globalPlayersState[leavingPlayerId]; 312 | if (leavingPlayerId === hostClientId) { 313 | quizRoomChannel.publish('host-left', { 314 | endQuiz: true 315 | }); 316 | forceQuizEnd(); 317 | } 318 | } 319 | ``` 320 | 321 | We’ll again update the `totalPlayers` count and let the parent thread know. We’ll also let the other players know of this by publishing a message on the `quizRoom` channel. If the leaving player was the host of the quiz, we’ll forcefully end the quiz as no one else can control the quiz. 322 | 323 | ##### The `publishTimer()` method: 324 | 325 | ```js 326 | async function publishTimer(event, countDownSec) { 327 | while (countDownSec > 0) { 328 | quizRoomChannel.publish(event, { 329 | countDownSec: countDownSec 330 | }); 331 | await new Promise((resolve) => setTimeout(resolve, 1000)); 332 | countDownSec -= 1; 333 | if (event === 'question-timer' && skipTimer) break; 334 | } 335 | } 336 | ``` 337 | 338 | This is an asynchronous function, meaning, it will finish executing the `setTimeout()` function before moving onto the execution of the next statement. We use this method to publish the timer from the server, to ensure that the front-end clients are always in-sync. 339 | 340 | There could be different kinds of timers, like the 5 sec timer before the quiz initially starts, or the 30 sec timer for every question. The two arguments for this function help determine that and act accordingly. If all the available players have answered a question, there’s no point waiting for the remaining time to elapse. In such a case, we set the `skipTimer` flag to true and skip the rest of the timer. 341 | 342 | ##### The `publishQuestion()` method: 343 | 344 | ```js 345 | async function publishQuestion(qIndex, isLast) { 346 | numPlayersAnswered = 0; 347 | await quizRoomChannel.publish('new-question', { 348 | numAnswered: 0, 349 | numPlaying: totalPlayers - 1, 350 | questionNumber: qIndex + 1, 351 | question: questions[qIndex].question, 352 | choices: questions[qIndex].choices, 353 | isLastQuestion: isLast, 354 | showImg: questions[qIndex].showImg, 355 | imgLink: questions[qIndex].pic 356 | }); 357 | skipTimer = false; 358 | await publishTimer('question-timer', QUESTION_TIMER_SEC); 359 | await quizRoomChannel.publish('correct-answer', { 360 | questionNumber: qIndex + 1, 361 | correctAnswerIndex: questions[qIndex].correct 362 | }); 363 | computeTopScorers(); 364 | 365 | if (isLast) { 366 | killWorkerThread(); 367 | } 368 | } 369 | ``` 370 | 371 | This method is pretty straightforward. It is also an async function. We start by publishing the question and its options, then the 30-sec timer, then the correct answer. We then call a method to compute the leaderboard info. If it was the last question, it means the quiz has come to an end and the worker thread is no longer needed. So we call a method to kill the thread. 372 | 373 | ##### The `forceQuizEnd()` method: 374 | 375 | ```js 376 | function forceQuizEnd() { 377 | quizRoomChannel.publish('quiz-ending', { 378 | quizEnding: true 379 | }); 380 | killWorkerThread(); 381 | } 382 | ``` 383 | 384 | This method is called when the host has requested to forcefully end the quiz midway through. In this method, we just let all the players know that the quiz has ended and kill the worker thread. 385 | 386 | ##### The `subscribeToPlayerChannel()` method: 387 | 388 | ```js 389 | function subscribeToPlayerChannel(playerChannel, playerId) { 390 | playerChannel.subscribe('player-answer', (msg) => { 391 | numPlayersAnswered++; 392 | if ( 393 | questions[msg.data.questionIndex].correct === msg.data.playerAnswerIndex 394 | ) { 395 | globalPlayersState[playerId].score += 5; 396 | } 397 | updateLiveStatsForHost(numPlayersAnswered, totalPlayers - 1); 398 | }); 399 | } 400 | ``` 401 | 402 | In this method, we subscribe to each player’s unique channel, identified partly by their unique `clientId`. When a player submits their answer to one of the questions, we save it in the state variable on our server and call a method to update the live stats for the host. 403 | 404 | ##### The `computeTopScorers()` method: 405 | 406 | ```js 407 | function computeTopScorers() { 408 | let leaderboard = new Array(); 409 | for (let item in globalPlayersState) { 410 | if (item != hostClientId) { 411 | leaderboard.push({ 412 | nickname: globalPlayersState[item].nickname, 413 | score: globalPlayersState[item].score 414 | }); 415 | } 416 | } 417 | leaderboard.sort((a, b) => b.score - a.score); 418 | hostAdminCh.publish('full-leaderboard', { 419 | leaderboard: leaderboard 420 | }); 421 | } 422 | ``` 423 | 424 | In this method, we simply sort the players in terms of their latest score and publish this info to the host on the host channel. 425 | 426 | ##### The `updateLiveStatsForHost()` method: 427 | 428 | ```js 429 | function updateLiveStatsForHost(numAnswered, numPlaying) { 430 | hostAdminCh.publish('live-stats-update', { 431 | numAnswered: numAnswered, 432 | numPlaying: numPlaying 433 | }); 434 | if (numAnswered === numPlaying) { 435 | skipTimer = true; 436 | } 437 | } 438 | ``` 439 | 440 | In this method, we publish the latest numbers on how many players have answered among the players who are still playing. If everyone has answered, we set the skip timer flag to true so the remaining time can be skipped. 441 | 442 | ##### The `killWorkerThread()` method: 443 | 444 | ```js 445 | function killWorkerThread() { 446 | console.log('killing thread'); 447 | for (const item in playerChannels) { 448 | if (playerChannels[item]) { 449 | playerChannels[item].detach(); 450 | } 451 | } 452 | hostAdminCh.detach(); 453 | quizRoomChannel.detach(); 454 | parentPort.postMessage({ 455 | killWorker: true, 456 | roomCode: roomCode, 457 | totalPlayers: totalPlayers 458 | }); 459 | process.exit(0); 460 | } 461 | ``` 462 | 463 | In this method we detach from all the channels, let the main thread know, and exit the process, thus killing the worker thread. 464 | 465 | The `quiz-default-questions.js` file simply exports a set of questions to use when the host requests a random quiz. 466 | 467 | That’s it on the server-side. 468 | 469 | ### Client-side code 470 | 471 | The Vue app on the front-end is created using the [Vue CLI](https://cli.vuejs.org/), which conveniently sets up a standard project folder with all the files needed to get up and running quickly. 472 | 473 | This project uses [Bootstrap](https://getbootstrap.com/) for basic styling and [Font Awesome](https://fontawesome.com/) for various icons. We have these libraries referenced in the `index.html` file. We also have a repeating background added in this file, which applies it to the whole app. 474 | 475 | The `routes.js` file defines a routes array to be used with the [Vue Router](https://router.vuejs.org/). 476 | 477 | The `main.js` file is the entry point to our app. It instantiates the [Vue Router](https://router.vuejs.org/) and the Vue instance and mounts the `App.vue` component. 478 | 479 | Let’s understand the various components along with the methods in each. 480 | 481 | **For the Host** 482 | 483 | When the host opens the app, the `` in the `App.vue` file becomes the `HostHome` component. 484 | 485 | 1. `HostHome.vue` 486 | 487 | In this component, we show the host two options to choose the type of quiz, i.e. custom or random. We receive the Ably Realtime instance and the unique client id as props from the `App.vue` component. When one of the options is chosen, we switch to the `CreateQuizRoom.vue` component. 488 | 489 | 2. `CreateQuizRoom.vue` 490 | 491 | **HTML** - We show the instructions to add custom questions in case of that option being chosen. For both types of quizzes, we show an input box for the host to enter their nickname and create a quiz room with the chosen quiz type and questions if applicable. 492 | 493 | **JS** - This is the main file in which we attach and subscribe to various Ably channels to receive updates and publish data. Let’s understand the methods in this component: 494 | - The `createQuizRoom()` method: 495 | 496 | ```js 497 | createQuizRoom() { 498 | this.createBtnClicked = true; 499 | if (this.quizType === 'RandomQuiz') { 500 | this.btnText = 'Creating your quiz room...'; 501 | } else { 502 | this.btnText = 'Loading your questions and creating your quiz room...'; 503 | let mySheetId = new RegExp('/spreadsheets/d/([a-zA-Z0-9-_]+)').exec( 504 | this.sheetURL 505 | )[1]; 506 | if (mySheetId == null || this.sheetURL == null) { 507 | this.sheetURLErr = true; 508 | return; 509 | } 510 | const options = { 511 | sheetId: mySheetId, 512 | sheetNumber: 1, 513 | returnAllResults: true 514 | }; 515 | GSheetReader( 516 | options, 517 | results => { 518 | this.customQuizQuestions = results; 519 | }, 520 | error => { 521 | this.sheetURLErr = true; 522 | console.log(error); 523 | return; 524 | } 525 | ); 526 | } 527 | this.waitForGameRoom(); 528 | this.enterMainThread(); 529 | } 530 | ``` 531 | 532 | This method is called when the host clicks on the create quiz room button. If the host chose the custom quiz option, we use the [GSheetReader library](https://www.npmjs.com/package/g-sheets-api) to fetch the questions in the required format from their Google sheet. 533 | 534 | We then add a method to wait to hear from the game room when it’s ready, then enter the main thread to trigger the actual creation of the game room by the server. 535 | 536 | - The `waitForGameRoom()` method: 537 | 538 | ```js 539 | waitForGameRoom() { 540 | this.myQuizRoomCh = this.realtime.channels.get( 541 | `${this.myQuizRoomCode}:primary` 542 | ); 543 | this.hostAdminCh = this.realtime.channels.get( 544 | `${this.myQuizRoomCode}:host` 545 | ); 546 | this.myQuizRoomCh.subscribe('thread-ready', () => { 547 | this.handleQuizRoomReady(); 548 | }); 549 | } 550 | ``` 551 | 552 | This method attaches to the quiz room and host channels and subscribes to the thread ready event on the quiz room. We call another method `handleQuizRoomReady()` when the callback to this is triggered. 553 | 554 | - The `enterMainThread()` method: 555 | 556 | ```js 557 | enterMainThread() { 558 | this.globalQuizCh = this.realtime.channels.get(this.globalQuizChName); 559 | this.globalQuizCh.presence.enter({ 560 | nickname: this.hostNickname, 561 | roomCode: this.myQuizRoomCode 562 | }); 563 | }, 564 | ``` 565 | 566 | In this method we attach to the global channel and enter the presence set on it. 567 | 568 | - The `handleQuizRoomReady()` method: 569 | 570 | ```js 571 | handleQuizRoomReady() { 572 | this.isRoomReady = true; 573 | this.globalQuizCh.detach(); 574 | this.enterGameRoomAndSubscribeToEvents(); 575 | this.playerLink = `${this.playerLinkBase}?quizCode=${this.myQuizRoomCode}`; 576 | if (this.quizType == 'CustomQuiz') { 577 | let questions = this.customQuizQuestions; 578 | this.hostAdminCh.publish('quiz-questions', { 579 | questions 580 | }); 581 | } 582 | }, 583 | ``` 584 | 585 | In this method, we detach from the global channel as we no longer need it and make the host enter the game room and subscribe to events. If the custom quiz option was chosen, we publish the questions extracted from their Google sheet. 586 | 587 | - The `enterGameRoomAndSubscribeToEvents()` method: 588 | 589 | ```js 590 | enterGameRoomAndSubscribeToEvents() { 591 | this.myQuizRoomCh.presence.enter({ 592 | nickname: this.hostNickname, 593 | avatarColor: this.myAvatarColor, 594 | isHost: true, 595 | quizType: this.quizType 596 | }); 597 | this.subscribeToHostChEvents(); 598 | this.subscribeToRoomChEvents(); 599 | }, 600 | ``` 601 | 602 | In this method, we enter presence on the quiz room channel with the initial attributes and subscribe to events on the host channel and the quiz room channel. 603 | 604 | - The `subscribeToHostChEvents()` method: 605 | 606 | ```js 607 | subscribeToHostChEvents() { 608 | this.hostAdminCh.subscribe('live-stats-update', msg => { 609 | this.numAnswered = msg.data.numAnswered; 610 | this.numPlaying = msg.data.numPlaying; 611 | }); 612 | this.hostAdminCh.subscribe('full-leaderboard', msg => { 613 | this.leaderboard = msg.data.leaderboard; 614 | }); 615 | }, 616 | ``` 617 | 618 | In this method, we subscribe to two host events, one for the live stats update and another for the leaderboard info. This info is shown during and after each question, respectively. 619 | 620 | - The `subscribeToRoomChEvents()` method: 621 | 622 | ```js 623 | subscribeToRoomChEvents() { 624 | this.myQuizRoomCh.subscribe('new-player', msg => { 625 | this.handleNewPlayerEntered(msg); 626 | }); 627 | this.myQuizRoomCh.subscribe('start-quiz-timer', msg => { 628 | this.didHostStartGame = true; 629 | this.timer = msg.data.countDownSec; 630 | }); 631 | this.myQuizRoomCh.subscribe('new-question', msg => { 632 | this.handleNewQuestionReceived(msg); 633 | }); 634 | this.myQuizRoomCh.subscribe('question-timer', msg => { 635 | this.questionTimer = msg.data.countDownSec; 636 | if (this.questionTimer < 0) { 637 | this.questionTimer = 30; 638 | } 639 | }); 640 | this.myQuizRoomCh.subscribe('correct-answer', msg => { 641 | this.handleCorrectAnswerReceived(msg); 642 | }); 643 | }, 644 | ``` 645 | 646 | In this method, we subscribe to a few events on the quiz room channel as described below: 647 | 648 | `new-player` - when a new player has joined 649 | 650 | `start-quiz-timer` - when the quiz start timer is supposed to be shown before starting the quiz 651 | 652 | `new-question` - to get the next question to be displayed 653 | 654 | `question-timer` - to show the synchronous timer when a question is displayed 655 | 656 | `correct-answer` - to receive the correct answer for the latest question displayed 657 | 658 | - The `handleNewPlayerEntered()` method: 659 | 660 | ```js 661 | handleNewPlayerEntered(msg) { 662 | let { clientId, nickname, avatarColor, isHost } = msg.data.newPlayerState; 663 | if (!isHost) { 664 | this.onlinePlayersArr.push({ 665 | clientId, 666 | nickname, 667 | avatarColor, 668 | isHost 669 | }); 670 | } else { 671 | return; 672 | } 673 | }, 674 | ``` 675 | 676 | In this method, we update the online players array with the newly joined player's details. 677 | 678 | - The `handleNewQuestionReceived()` method: 679 | 680 | ```js 681 | handleNewQuestionReceived(msg) { 682 | this.showAnswer = false; 683 | this.showQuestions = true; 684 | this.newQuestionNumber = msg.data.questionNumber; 685 | this.newQuestion = msg.data.question; 686 | this.newChoices = msg.data.choices; 687 | this.isLastQuestion = msg.data.isLastQuestion; 688 | this.numAnswered = msg.data.numAnswered; 689 | this.numPlaying = msg.data.numPlaying; 690 | this.showImg = msg.data.showImg; 691 | this.questionImgLink = msg.data.imgLink; 692 | }, 693 | ``` 694 | 695 | In this method, we simply save the data that we receive from the server locally, to be displayed on the UI next. 696 | 697 | - The `handleCorrectAnswerReceived()` method: 698 | 699 | ```js 700 | handleCorrectAnswerReceived(msg) { 701 | this.showAnswer = true; 702 | if (this.newQuestionNumber == msg.data.questionNumber) { 703 | this.correctAnswerIndex = msg.data.correctAnswerIndex; 704 | } 705 | if (this.isLastQuestion) { 706 | this.showFinalScreen = true; 707 | } 708 | }, 709 | ``` 710 | 711 | In this method also, we save the correct answer received and show it on the screen. If the previously displayed question was the last, then we switch to the final screen that shows the leaderboard info. 712 | 713 | - The `copyPlayerInviteLink()` method: 714 | 715 | ```js 716 | copyPlayerInviteLink() { 717 | this.copyClicked = true; 718 | this.copyBtnText = 'Copied!'; 719 | setTimeout(() => { 720 | this.copyClicked = false; 721 | this.copyBtnText = 'Copy shareable link'; 722 | }, 2000); 723 | navigator.clipboard.writeText(this.playerLink); 724 | }, 725 | ``` 726 | 727 | A utility method to let the user copy the invite link by simply clicking a button. 728 | 729 | - The `getRandomRoomId()` method: 730 | 731 | ```js 732 | getRandomRoomId() { 733 | return ( 734 | 'room-' + 735 | Math.random() 736 | .toString(36) 737 | .substr(2, 8) 738 | ); 739 | } 740 | ``` 741 | 742 | A utility method to generate a random room code to uniquely identify the quiz room 743 | 744 | - The `startQuiz()` and `endQuiz()` methods: 745 | 746 | ```js 747 | startQuiz() { 748 | this.hostAdminCh.publish('start-quiz', { 749 | start: true 750 | }); 751 | }, 752 | endQuizNow() { 753 | this.showFinalScreen = true; 754 | } 755 | ``` 756 | 757 | In these methods, we publish the start quiz event and show the final screen (which will show the leaderboard) respectively. 758 | 759 | - `beforeDestroy()` 760 | 761 | ```js 762 | beforeDestroy() { 763 | if (this.myQuizRoomCh) { 764 | this.myQuizRoomCh.presence.leave(); 765 | } 766 | this.questionTimer = 30; 767 | } 768 | ``` 769 | 770 | This is a component lifecycle method which is invoked just before the `CreateQuizRoom.vue` component is destroyed. In this method, we have the host leave the presence set on the quiz room channel and reset the question timer. 771 | 772 | 3. `AdminPanel.vue` 773 | 774 | **HTML** - We show the options to show the next question or end quiz midway through. 775 | 776 | **JS** - As per the button clicked, we simply publish an event on the host channel. In case the host chooses the end quiz option, we also emit an event for the `CreateQuizRoom.vue` component to be able to update the view accordingly. In this case, we show the full leaderboard. 777 | 778 | ```js 779 | showNextQuestion() { 780 | this.hostAdminCh.publish('next-question', { 781 | prevQIndex: this.prevQuestionNumber - 1 782 | }); 783 | }, 784 | endQuizNow() { 785 | this.hostAdminCh.publish('end-quiz-now', { 786 | end: true 787 | }); 788 | this.$emit('end-quiz-now'); 789 | } 790 | ``` 791 | 792 | 4. `LiveStats.vue` 793 | 794 | In this component we show the live stats relating to the number of players still online and among those the number of players who've already answered a particular question. 795 | 796 | 5. `Leaderboard.vue` 797 | 798 | In this component we show the leaderboard. There are two versions of this. If the quiz is still ongoing, only the top five scorers will be shown but if the quiz has ended, a full list of people with their scores will be displayed by this component. 799 | 800 | **For the Player** 801 | 802 | When the player joins the quiz via the shareable link shared by their host, they'll be hitting the `/play` endpoint with some parameters. The `vue-router` that we set up will redirect all requests to the `/play` endpoint to show the `PlayerHome.vue` component. The methods in this component are similar to the ones in the `CreateQuizRoom.vue` component but are repeated to make the host and player apps look separate. 803 | 804 | One thing to note in the `PlayerHome.vue` component's `created()` lifecycle method, is the use of the Axios library to send a request to our server: 805 | 806 | ```js 807 | async created() { 808 | this.quizRoomCode = this.$route.query.quizCode; 809 | await axios 810 | .get('/checkRoomStatus?quizCode=' + this.quizRoomCode) 811 | .then(roomStatusInfo => { 812 | this.isRoomClosed = roomStatusInfo.data.isRoomClosed; 813 | }); 814 | this.myQuizRoomCh = this.realtime.channels.get( 815 | `${this.quizRoomCode}:primary` 816 | ); 817 | this.myAvatarColor = 818 | '#' + 819 | Math.random() 820 | .toString(16) 821 | .slice(-6); 822 | }, 823 | ``` 824 | 825 | This async method allows the player app to send an additional request to the server to check if the quiz room is already closed due to the quiz starting or ending or if it's ok for this player to enter. The view is be updated accordingly. 826 | 827 | **Common components** 828 | 829 | 1. `OnlinePlayers.vue` 830 | 831 | This component appears on the staging area for the host and players. It shows a list of online players which updates as new people join. The host can use this list to determine if they are ready to start the quiz. 832 | 833 | 2. `Question.vue` 834 | 835 | This component displays the question, four options and optionally an image. For the host, the options are not clickable as they won't be participating in the quiz but the players will have clickable buttons for the options. 836 | 837 | 3. `Answer.vue` 838 | 839 | This component displays the correct answer for the previously displayed question. For this host, this component will replace the options in the questions component, whereas for the players, this will be shown as a standalone component and also indicates if the option they chose was correct or not. 840 | 841 | --- 842 | 843 | That's all the code! All the components are extensible and can be used as a starting point to customize the app as per your requirements. 844 | 845 | If you have any questions, feel free to [give me a shout on Twitter](https://twitter.com/Srushtika) or [reach out to the support team at Ably](mailto:support@ably.com). 846 | -------------------------------------------------------------------------------- /assets/96634642-b6ac0d00-1312-11eb-9ebb-0b7cd9122fc3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/assets/96634642-b6ac0d00-1312-11eb-9ebb-0b7cd9122fc3.png -------------------------------------------------------------------------------- /assets/96634661-bad82a80-1312-11eb-9e61-661e42eaf485.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/assets/96634661-bad82a80-1312-11eb-9e61-661e42eaf485.png -------------------------------------------------------------------------------- /assets/96634667-bd3a8480-1312-11eb-80ea-5a72ef329692.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/assets/96634667-bd3a8480-1312-11eb-80ea-5a72ef329692.png -------------------------------------------------------------------------------- /assets/96635120-608b9980-1313-11eb-86b7-059ff18e7d1e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/assets/96635120-608b9980-1313-11eb-86b7-059ff18e7d1e.png -------------------------------------------------------------------------------- /assets/a-simplified-architecture-for-multiplayer-quiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/assets/a-simplified-architecture-for-multiplayer-quiz.png -------------------------------------------------------------------------------- /assets/ably-channels-for-multiplayer-quiz-implementation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/assets/ably-channels-for-multiplayer-quiz-implementation.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live-edtech-trivia-app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js", 9 | "lint": "npx eslint ./realtime-quiz/**/*.vue ." 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Srushtika/live-edtech-trivia-app.git" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/Srushtika/live-edtech-trivia-app/issues" 19 | }, 20 | "homepage": "https://github.com/Srushtika/live-edtech-trivia-app#readme", 21 | "dependencies": { 22 | "ably": "^1.2.3", 23 | "dotenv": "^8.2.0", 24 | "express": "^4.17.1", 25 | "path": "^0.12.7", 26 | "serve-static": "^1.14.1" 27 | }, 28 | "devDependencies": { 29 | "babel-eslint": "^10.1.0", 30 | "eslint": "^7.11.0", 31 | "eslint-plugin-vue": "^7.0.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /quiz-questions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "questionNumber": 1, 4 | "question": "How fast is the blink of an eye?", 5 | "choices": ["2 seconds", "1 second", "0.5 seconds", "0.2 seconds"], 6 | "showImg": true, 7 | "correct": 3, 8 | "pic": "https://media.giphy.com/media/2AX09OcYVEsUM/giphy.gif" 9 | }, 10 | { 11 | "questionNumber": 2, 12 | "question": "How fast is Ably? What is Ably's average roundtrip latency globally?", 13 | "choices": ["1 second", "3 seconds", "0.065 seconds", "0.001 seconds"], 14 | "showImg": true, 15 | "correct": 2, 16 | "pic": "https://media.giphy.com/media/7lz6nPd56aHh6/giphy.gif" 17 | }, 18 | { 19 | "questionNumber": 3, 20 | "question": "How many children moved to remote interactive digitial learning in 2020?", 21 | "choices": ["1.21 billion", "568 million", "3.46 billion", "1.57 billion"], 22 | "showImg": true, 23 | "correct": 3, 24 | "pic": "https://media.giphy.com/media/4GZyVJkdSHTImp74RP/giphy.gif" 25 | }, 26 | { 27 | "questionNumber": 4, 28 | "question": "What does Ably not yet power for children learning via online EdTech?", 29 | "choices": [ 30 | "Online proctoring", 31 | "Collaborative whiteboards", 32 | "Telepathy with their friends", 33 | "Chat" 34 | ], 35 | "showImg": true, 36 | "correct": 2, 37 | "pic": "https://media.giphy.com/media/q6gzszRDonw3u/giphy.gif" 38 | } 39 | ] 40 | -------------------------------------------------------------------------------- /quiz-room-server.js: -------------------------------------------------------------------------------- 1 | const randomQuestions = require('./quiz-questions.json'); 2 | const { parentPort, workerData } = require('worker_threads'); 3 | const Ably = require('ably/promises'); 4 | const START_TIMER_SEC = 5; 5 | const QUESTION_TIMER_SEC = 30; 6 | 7 | const ABLY_API_KEY = process.env.ABLY_API_KEY; 8 | const globalPlayersState = {}; 9 | const playerChannels = {}; 10 | let didQuizStart = false; 11 | let totalPlayers = 0; 12 | const quizRoomChName = `${workerData.hostRoomCode}:primary`; 13 | const hostAdminChName = `${workerData.hostRoomCode}:host`; 14 | let hostAdminCh; 15 | const roomCode = workerData.hostRoomCode; 16 | const hostClientId = workerData.hostClientId; 17 | let quizRoomChannel; 18 | let numPlayersAnswered = 0; 19 | let customQuestions = []; 20 | let skipTimer = false; 21 | 22 | console.log('this is the worker thread'); 23 | console.log('room code is' + workerData.hostRoomCode); 24 | 25 | let questions = []; 26 | 27 | const realtime = new Ably.Realtime({ 28 | key: ABLY_API_KEY, 29 | echoMessages: false 30 | }); 31 | 32 | realtime.connection.once('connected', () => { 33 | hostAdminCh = realtime.channels.get(hostAdminChName); 34 | quizRoomChannel = realtime.channels.get(quizRoomChName); 35 | 36 | subscribeToHostEvents(); 37 | 38 | quizRoomChannel.presence.subscribe('enter', handleNewPlayerEntered); 39 | quizRoomChannel.presence.subscribe('leave', handleExistingPlayerLeft); 40 | quizRoomChannel.publish('thread-ready', { start: true }); 41 | }); 42 | 43 | function handleNewPlayerEntered(player) { 44 | console.log(player.clientId + 'player entered quiz room'); 45 | const newPlayerId = player.clientId; 46 | totalPlayers++; 47 | parentPort.postMessage({ 48 | roomCode: roomCode, 49 | totalPlayers: totalPlayers, 50 | didQuizStart: didQuizStart 51 | }); 52 | 53 | let newPlayerState = { 54 | id: newPlayerId, 55 | nickname: player.data.nickname, 56 | avatarColor: player.data.avatarColor, 57 | isHost: player.data.isHost, 58 | score: 0 59 | }; 60 | 61 | if (player.data.isHost) { 62 | let quizType = player.data.quizType; 63 | quizType === 'CustomQuiz' 64 | ? (questions = customQuestions) 65 | : (questions = randomQuestions); 66 | } else { 67 | playerChannels[newPlayerId] = realtime.channels.get( 68 | `${roomCode}:player-ch-${player.clientId}` 69 | ); 70 | 71 | subscribeToPlayerChannel(playerChannels[newPlayerId], newPlayerId); 72 | } 73 | 74 | globalPlayersState[newPlayerId] = newPlayerState; 75 | quizRoomChannel.publish('new-player', { 76 | newPlayerState 77 | }); 78 | } 79 | 80 | function handleExistingPlayerLeft(player) { 81 | console.log('leaving player', player.clientId); 82 | const leavingPlayerId = player.clientId; 83 | totalPlayers--; 84 | parentPort.postMessage({ 85 | roomCode: roomCode, 86 | totalPlayers: totalPlayers 87 | }); 88 | delete globalPlayersState[leavingPlayerId]; 89 | if (leavingPlayerId === hostClientId) { 90 | quizRoomChannel.publish('host-left', { 91 | endQuiz: true 92 | }); 93 | forceQuizEnd(); 94 | } 95 | } 96 | 97 | async function publishTimer(event, countDownSec) { 98 | while (countDownSec > 0) { 99 | quizRoomChannel.publish(event, { 100 | countDownSec: countDownSec 101 | }); 102 | await new Promise((resolve) => setTimeout(resolve, 1000)); 103 | countDownSec -= 1; 104 | if (event === 'question-timer' && skipTimer) break; 105 | } 106 | } 107 | 108 | function subscribeToHostEvents() { 109 | hostAdminCh.subscribe('start-quiz', async () => { 110 | didQuizStart = true; 111 | parentPort.postMessage({ 112 | roomCode, 113 | didQuizStart 114 | }); 115 | await publishTimer('start-quiz-timer', START_TIMER_SEC); 116 | publishQuestion(0, false); 117 | }); 118 | 119 | hostAdminCh.subscribe('quiz-questions', (msg) => { 120 | for (let i = 0; i < msg.data.questions.length; i++) { 121 | let item = msg.data.questions[i]; 122 | let newQuestionObject = { 123 | questionNumber: parseInt(item['question number']), 124 | showImg: item['image link'].substr(0, 4) === 'http' ? true : false, 125 | question: item.question, 126 | choices: [ 127 | item['option 1'], 128 | item['option 2'], 129 | item['option 3'], 130 | item['option 4'] 131 | ], 132 | correct: parseInt(item['correct answer option number']) - 1, 133 | pic: item['image link'] 134 | }; 135 | customQuestions.push(newQuestionObject); 136 | } 137 | }); 138 | 139 | hostAdminCh.subscribe('next-question', (msg) => { 140 | let prevQIndex = msg.data.prevQIndex; 141 | let newQIndex = prevQIndex + 1; 142 | let lastQIndex = questions.length - 1; 143 | if (newQIndex < lastQIndex) { 144 | publishQuestion(newQIndex, false); 145 | } else if (newQIndex === lastQIndex) { 146 | publishQuestion(newQIndex, true); 147 | } 148 | }); 149 | 150 | hostAdminCh.subscribe('end-quiz-now', () => { 151 | forceQuizEnd(); 152 | }); 153 | } 154 | 155 | function forceQuizEnd() { 156 | quizRoomChannel.publish('quiz-ending', { 157 | quizEnding: true 158 | }); 159 | killWorkerThread(); 160 | } 161 | 162 | async function publishQuestion(qIndex, isLast) { 163 | numPlayersAnswered = 0; 164 | await quizRoomChannel.publish('new-question', { 165 | numAnswered: 0, 166 | numPlaying: totalPlayers - 1, 167 | questionNumber: qIndex + 1, 168 | question: questions[qIndex].question, 169 | choices: questions[qIndex].choices, 170 | isLastQuestion: isLast, 171 | showImg: questions[qIndex].showImg, 172 | imgLink: questions[qIndex].pic 173 | }); 174 | skipTimer = false; 175 | await publishTimer('question-timer', QUESTION_TIMER_SEC); 176 | await quizRoomChannel.publish('correct-answer', { 177 | questionNumber: qIndex + 1, 178 | correctAnswerIndex: questions[qIndex].correct 179 | }); 180 | computeTopScorers(); 181 | 182 | if (isLast) { 183 | killWorkerThread(); 184 | } 185 | } 186 | 187 | function computeTopScorers() { 188 | let leaderboard = new Array(); 189 | for (let item in globalPlayersState) { 190 | if (item != hostClientId) { 191 | leaderboard.push({ 192 | nickname: globalPlayersState[item].nickname, 193 | score: globalPlayersState[item].score 194 | }); 195 | } 196 | } 197 | leaderboard.sort((a, b) => b.score - a.score); 198 | quizRoomChannel.publish('full-leaderboard', { 199 | leaderboard: leaderboard 200 | }); 201 | } 202 | 203 | function subscribeToPlayerChannel(playerChannel, playerId) { 204 | playerChannel.subscribe('player-answer', (msg) => { 205 | numPlayersAnswered++; 206 | if ( 207 | questions[msg.data.questionIndex].correct === msg.data.playerAnswerIndex 208 | ) { 209 | globalPlayersState[playerId].score += 5; 210 | } 211 | updateLiveStatsForHost(numPlayersAnswered, totalPlayers - 1); 212 | }); 213 | updateLiveStatsForHost(numPlayersAnswered, totalPlayers - 1); 214 | } 215 | 216 | function updateLiveStatsForHost(numAnswered, numPlaying) { 217 | quizRoomChannel.publish('live-stats-update', { 218 | numAnswered: numAnswered, 219 | numPlaying: numPlaying 220 | }); 221 | if (numAnswered === numPlaying) { 222 | skipTimer = true; 223 | } 224 | } 225 | 226 | function killWorkerThread() { 227 | console.log('killing thread'); 228 | for (const item in playerChannels) { 229 | if (playerChannels[item]) { 230 | playerChannels[item].detach(); 231 | } 232 | } 233 | hostAdminCh.detach(); 234 | quizRoomChannel.detach(); 235 | parentPort.postMessage({ 236 | killWorker: true, 237 | roomCode: roomCode, 238 | totalPlayers: totalPlayers 239 | }); 240 | process.exit(0); 241 | } 242 | -------------------------------------------------------------------------------- /realtime-quiz/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /realtime-quiz/README.md: -------------------------------------------------------------------------------- 1 | # realtime-quiz 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /realtime-quiz/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /realtime-quiz/dist/bg-shapes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/realtime-quiz/dist/bg-shapes.png -------------------------------------------------------------------------------- /realtime-quiz/dist/css/app.a1d562e2.css: -------------------------------------------------------------------------------- 1 | #app{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#2c3e50;margin-top:30px}.answer-card[data-v-10651dd2]{width:60%;margin:20px auto;text-align:center}.answer-eval[data-v-10651dd2]{display:flex;justify-content:center;align-items:center;gap:10px}.answer-div[data-v-10651dd2]{max-height:200px;padding:10px}.answer-text[data-v-10651dd2]{margin:20px}@media only screen and (max-device-width:480px){.answer-card[data-v-10651dd2]{width:90%;margin:20px auto;text-align:center}}.questions-card-player[data-v-665da564]{width:60%;margin:20px auto;text-align:center}.questions-card-host[data-v-665da564]{width:90%;margin:20px auto;text-align:center}.question-div[data-v-665da564]{max-height:200px;margin:20px}.img-div[data-v-665da564]{margin:0 auto;max-width:50%}.q-img[data-v-665da564]{max-height:200px}.choices-container[data-v-665da564]{display:flex;flex-wrap:wrap;justify-content:space-between}.choice-btn[data-v-665da564],.choice-div[data-v-665da564]{flex:0 46%;height:60px;margin:2%}.choice-div[data-v-665da564]{line-height:60px;border:thin solid grey}.submitted-msg[data-v-665da564]{text-align:center;margin:0 auto;color:grey}@media only screen and (max-device-width:480px){.questions-card-player[data-v-665da564]{width:90%;margin:10px auto;text-align:center}.question-div[data-v-665da564]{max-height:100px;margin:20px;font-size:15px}.img-div[data-v-665da564]{margin:0 auto;max-width:50%}.q-img[data-v-665da564]{max-height:100px}.choices-container[data-v-665da564]{display:block}.choice-btn[data-v-665da564],.choice-div[data-v-665da564]{width:100%;height:60px;margin:2%}.choice-div[data-v-665da564]{border:grey}.submitted-msg[data-v-665da564]{text-align:center;margin:0 auto;color:grey}}.online-players[data-v-2abb634e]{display:flex;align-items:center;justify-content:center;flex-direction:row;flex-wrap:wrap;align-content:center;margin:20px auto;max-width:80%}.player-avatar[data-v-2abb634e]{width:70px;height:70px;max-width:70px;max-height:70px}button[data-v-2abb634e]{margin:5px;width:60%;font-size:20px;background:#ff5416;background:linear-gradient(90deg,#ff5416 75%,#e40000);border:1px solid #fff}button[data-v-2abb634e]:hover{background:#fff;color:#e40000;border:1px solid #e40000}@media only screen and (max-device-width:480px){.online-players[data-v-2abb634e]{display:flex;align-items:center;justify-content:center;flex-direction:row;flex-wrap:wrap;align-content:center;margin:20px auto;max-width:80%}}.leaderboard-host[data-v-5ef033dc],.leaderboard-player[data-v-5ef033dc],.livestats-div[data-v-125d4578]{width:90%;margin:20px auto;text-align:center}.leaderboard-player[data-v-5ef033dc]{max-width:900px}.score-list[data-v-5ef033dc]{width:50%;margin:0 auto;padding:10px}.score-item[data-v-5ef033dc]{display:flex;justify-content:space-between}@media only screen and (max-device-width:480px){.leaderboard-player[data-v-5ef033dc]{width:90%;text-align:center;margin:20px auto}}.alert-quiz-ended[data-v-54307770]{width:60%;margin:20px auto;text-align:center}.player-home[data-v-54307770]{margin:0 auto;text-align:center;width:60%;max-width:900px}.nickname-input[data-v-54307770]{display:flex;justify-content:space-evenly;width:60%;text-align:center;margin:0 auto}.player-leaderboard[data-v-54307770]{width:60%}button[data-v-54307770]{background:#ff5416;background:linear-gradient(90deg,#ff5416 75%,#e40000);border:1px solid #fff;color:#fff}button[data-v-54307770]:active,button[data-v-54307770]:hover{background:#fff;color:#e40000;border:1px solid #e40000}.div-black[data-v-54307770]{background-color:#03020d;color:#fff}.link[data-v-54307770]{color:#fff}.live-stats[data-v-54307770]{width:50%;margin:0 auto;text-align:center}.quiz-end-player[data-v-54307770]{color:#fff;margin:auto;text-align:center;font-size:2rem}@media only screen and (max-device-width:480px){.player-home[data-v-54307770]{margin:0 auto;text-align:center;width:90%}.nickname-input[data-v-54307770]{display:flex;justify-content:space-evenly;width:70%;text-align:center;margin:0 auto}.alert-quiz-ended[data-v-54307770]{width:90%;margin:20px auto;text-align:center}.live-stats[data-v-54307770]{width:100%;margin:20px auto;text-align:center}}.admin-panel[data-v-002967ae]{width:90%;margin:20px auto;text-align:center}.btn-next[data-v-002967ae]{margin-bottom:20px}.end-btn[data-v-002967ae]{font-size:12px;margin:0}.host-home[data-v-18d71a82]{margin:0 auto;text-align:center;width:60%}.input-box[data-v-18d71a82]{width:40%;margin:20px auto;text-align:center}.sheets-template[data-v-18d71a82]{text-align:center;background-color:#f1f5f6;margin:15px auto;padding:25px;width:100%}.template-instructions[data-v-18d71a82]{margin:20px auto}.sheet-error[data-v-18d71a82]{margin:20px}.question-flex[data-v-18d71a82]{width:65%}.stats-flex[data-v-18d71a82]{width:50%}.quizEnded[data-v-18d71a82]{width:80%;margin:20px auto;font-size:20px}.end-msg[data-v-18d71a82]{text-align:center;margin:10px auto}button[data-v-18d71a82]{margin:5px;width:60%;font-size:20px;background:#ff5416;background:linear-gradient(90deg,#ff5416 75%,#e40000);border:1px solid #fff;color:#fff}button[data-v-18d71a82]:hover{background:#fff;color:#e40000;border:1px solid #e40000}.back-btn[data-v-18d71a82]{background:none;border:none;color:#fff;padding:0;margin:0}.div-black[data-v-18d71a82]{background-color:#03020d;color:#fff}.back-btn[data-v-18d71a82]:hover{border:none;background:none;color:#fff}.orange-txt[data-v-18d71a82]{color:#ff5416}@media only screen and (max-device-width:480px){.host-home[data-v-18d71a82]{margin:0 auto;text-align:center;width:90%;font-size:.9rem}.nickname-input[data-v-18d71a82]{display:flex;justify-content:space-evenly;width:70%;text-align:center;margin:0 auto}.alert-quiz-ended[data-v-18d71a82]{width:90%;margin:20px auto;text-align:center}.home-text[data-v-18d71a82]{font-size:.8rem}button[data-v-18d71a82]{width:90%;font-size:1rem}}.host-home[data-v-2decba5d]{margin:0 auto;text-align:center;width:90%;max-width:900px}button[data-v-2decba5d]{margin:5px;width:60%;font-size:20px;background:#ff5416;background:linear-gradient(90deg,#ff5416 75%,#e40000);border:1px solid #fff;color:#fff}button[data-v-2decba5d]:hover{background:#fff;color:#e40000;border:1px solid #e40000}.img-header[data-v-2decba5d]{width:100%;background-color:#03020d;border-bottom:1px solid grey}.card-img-top[data-v-2decba5d]{width:80%}.footer-black[data-v-2decba5d]{background-color:#03020d;color:#fff}.link[data-v-2decba5d]{color:#fff}.mobile-msg[data-v-2decba5d]{color:red}@media only screen and (max-device-width:480px){.host-home[data-v-2decba5d]{margin:0 auto;text-align:center;width:90%}.home-text[data-v-2decba5d]{font-size:.8rem}button[data-v-2decba5d]{width:90%;font-size:1rem}} -------------------------------------------------------------------------------- /realtime-quiz/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/realtime-quiz/dist/favicon.ico -------------------------------------------------------------------------------- /realtime-quiz/dist/index.html: -------------------------------------------------------------------------------- 1 | Live trivia
-------------------------------------------------------------------------------- /realtime-quiz/dist/js/app.db17cef0.js: -------------------------------------------------------------------------------- 1 | (function(e){function t(t){for(var i,r,o=t[0],l=t[1],u=t[2],d=0,h=[];d0?[e.didHostStartGame?e._e():s("div",[s("hr"),s("button",{staticClass:"btn",attrs:{type:"button"},on:{click:function(t){return e.startQuiz()}}},[e._v(" Start the quiz ")])])]:e._e()],2):s("div",{staticClass:"card-body"},[s("h2",{staticClass:"card-title"},[e._v(" Host "+e._s("CustomQuiz"===e.quizType?"your own ":"a randomly chosen ")+" quiz ")]),"CustomQuiz"==e.quizType?[s("div",[s("p",{staticClass:"card-text"},[e._v(" You can add your own quiz questions in Google Sheets and host a live quiz. Simply make a copy of the template and fill it with your data. ")]),s("div",{staticClass:"sheets-template"},[s("a",{staticClass:"orange-txt",attrs:{href:e.templateCopyURL,target:"_blank",role:"button"}},[e._v(" Get the Google Sheets template")]),s("p",{staticClass:"card-text template-instructions"},[e._v(" After you've prepared the questions and answers, you need to do two things: "),s("br"),e._v(" 1. Copy the URL of your sheet from the browser's address bar and paste it in the field below "),s("input",{directives:[{name:"model",rawName:"v-model",value:e.sheetURL,expression:"sheetURL"}],staticClass:"form-control input-box",attrs:{placeholder:"Add the URL to your sheet",disabled:e.createBtnClicked},domProps:{value:e.sheetURL},on:{input:function(t){t.target.composing||(e.sheetURL=t.target.value)}}}),e._v(" 2. Make your Google sheet publicly available by going to File > Publish to the web > Publish. You might be presented with a different shareable URL, you can ignore that. ")])])])]:e._e(),s("p",{staticClass:"card-text"},[e._v(" We need a nickname for you so the players of your quiz can identify you ")]),s("input",{directives:[{name:"model",rawName:"v-model",value:e.hostNickname,expression:"hostNickname"}],staticClass:"form-control input-box",attrs:{placeholder:"Enter nickname",disabled:e.createBtnClicked},domProps:{value:e.hostNickname},on:{keyup:function(t){return!t.type.indexOf("key")&&e._k(t.keyCode,"enter",13,t.key,"Enter")?null:e.createQuizRoom()},input:function(t){t.target.composing||(e.hostNickname=t.target.value)}}}),s("button",{staticClass:"btn",attrs:{type:"button create-random-btn",disabled:e.createBtnClicked},on:{click:function(t){return e.createQuizRoom()}}},[e._v(" "+e._s(e.btnText)+" ")]),e.sheetURLErr?s("div",{staticClass:"alert alert-danger sheet-error",attrs:{role:"alert"}},[e._v(" There is a problem with the URL to your sheet. Please recheck it per the instructions above, refresh this page and try again. You can reach out to support@ably.com for further assistance. ")]):e._e()],2),s("div",{staticClass:"card-footer text-muted div-black"},[s("button",{staticClass:"btn btn-link back-btn",attrs:{type:"button"},on:{click:function(t){return e.showHome()}}},[e._v(" ← Go back ")])])]),e.showQuestions&&!e.showFinalScreen?s("div",{staticClass:"d-flex bd-highlight"},[s("div",{staticClass:"question-flex bd-highlight"},[s("Question",{attrs:{newQuestion:e.newQuestion,newChoices:e.newChoices,newQuestionNumber:e.newQuestionNumber,isLastQuestion:e.isLastQuestion,questionTimer:e.questionTimer,correctAnswerIndex:e.correctAnswerIndex,showImg:e.showImg,questionImgLink:e.questionImgLink,isAdminView:!0,correctAnswer:e.newChoices[e.correctAnswerIndex],showAnswer:e.showAnswer}})],1),s("div",{staticClass:"stats-flex bd-highlight"},[e.showAnswer?e._e():s("LiveStats",{attrs:{numAnswered:e.numAnswered,numPlaying:e.numPlaying}}),e.showAnswer?s("div",[s("Leaderboard",{attrs:{leaderboard:e.leaderboard,isPlayer:!1,finalScreen:!1}}),s("AdminPanel",{attrs:{hostAdminCh:e.hostAdminCh,prevQuestionNumber:e.newQuestionNumber},on:{"end-quiz-now":function(t){return e.endQuizNow()}}})],1):e._e()],1)]):e._e(),e.showFinalScreen?s("div",{staticClass:"quizEnded"},[e._m(0),s("Leaderboard",{attrs:{leaderboard:e.leaderboard,isPlayer:!1,finalScreen:!0}})],1):e._e()])},te=[function(){var e=this,t=e.$createElement,s=e._self._c||t;return s("div",{staticClass:"text-white end-msg"},[s("h6",[e._v("The quiz has ended")]),s("h1",{staticClass:"display-4"},[e._v("Congratulations to the winners 🎉🎉🎉")])])}],se=(s("4d63"),s("ac1f"),function(){var e=this,t=e.$createElement,s=e._self._c||t;return s("div",{staticClass:"alert alert-secondary admin-panel",attrs:{role:"alert"}},[s("h4",{staticClass:"alert-heading"},[e._v("Host control panel")]),s("hr"),s("button",{staticClass:"btn btn-dark btn-next",attrs:{type:"button"},on:{click:function(t){return e.showNextQuestion()}}},[e._v(" Show next question ")]),s("br"),s("button",{staticClass:"btn btn-link end-btn",attrs:{type:"button"},on:{click:function(t){return e.endQuizNow()}}},[e._v(" End quiz and show results ")])])}),ie=[],ne={name:"AdminPanel",props:["hostAdminCh","prevQuestionNumber"],methods:{showNextQuestion:function(){this.hostAdminCh.publish("next-question",{prevQIndex:this.prevQuestionNumber-1})},endQuizNow:function(){this.hostAdminCh.publish("end-quiz-now",{end:!0}),this.$emit("end-quiz-now")}}},ae=ne,re=(s("f709"),Object(c["a"])(ae,se,ie,!1,null,"002967ae",null)),oe=re.exports,le=s("b0c2"),ue={name:"QuizType",props:["resetCmpFn","realtime","quizType","showHome"],components:{Question:q,AdminPanel:oe,LiveStats:j,Leaderboard:M,OnlinePlayers:T},data:function(){return{globalQuizChName:"main-quiz-thread",globalQuizCh:null,myQuizRoomCode:this.getRandomRoomId(),myQuizRoomCh:null,hostAdminCh:"a",hostNickname:null,btnText:"Create my quiz room",createBtnClicked:!1,isRoomReady:!1,playerLinkBase:window.location.href+"play",playerLink:null,copyBtnText:"Copy shareable link",copyClicked:!1,onlinePlayersArr:[],didHostStartGame:!1,timer:null,showQuestions:!1,newQuestionNumber:null,newQuestion:null,newChoices:[],isLastQuestion:null,questionTimer:30,correctAnswerIndex:null,showAnswer:!1,numAnswered:0,numPlaying:0,leaderboard:null,templateCopyURL:"https://docs.google.com/spreadsheets/d/12_Cnv86fI4JOnJq5t9BQmxiPTNZgMsd0PP7Sbjm7WkQ/copy?usp=sharing",sheetURL:"",sheetURLErr:!1,customQuizQuestions:null,showImg:!1,questionImgLink:null,showFinalScreen:!1}},methods:{createQuizRoom:function(){var e=this;if(this.createBtnClicked=!0,"RandomQuiz"===this.quizType)this.btnText="Creating your quiz room...";else{this.btnText="Loading your questions and creating your quiz room...";var t=new RegExp("/spreadsheets/d/([a-zA-Z0-9-_]+)").exec(this.sheetURL)[1];if(null==t||null==this.sheetURL)return void(this.sheetURLErr=!0);var s={sheetId:t,sheetNumber:1,returnAllResults:!0};le(s,(function(t){e.customQuizQuestions=t}),(function(t){e.sheetURLErr=!0,console.log(t)}))}this.waitForGameRoom(),this.enterMainThread()},waitForGameRoom:function(){var e=this;this.myQuizRoomCh=this.realtime.channels.get("".concat(this.myQuizRoomCode,":primary")),this.hostAdminCh=this.realtime.channels.get("".concat(this.myQuizRoomCode,":host")),this.myQuizRoomCh.subscribe("thread-ready",(function(){e.handleQuizRoomReady()}))},handleQuizRoomReady:function(){if(this.isRoomReady=!0,this.globalQuizCh.detach(),this.enterGameRoomAndSubscribeToEvents(),this.playerLink="".concat(this.playerLinkBase,"?quizCode=").concat(this.myQuizRoomCode),"CustomQuiz"==this.quizType){var e=this.customQuizQuestions;this.hostAdminCh.publish("quiz-questions",{questions:e})}},enterGameRoomAndSubscribeToEvents:function(){this.myQuizRoomCh.presence.enter({nickname:this.hostNickname,avatarColor:this.myAvatarColor,isHost:!0,quizType:this.quizType}),this.subscribeToRoomChEvents()},enterMainThread:function(){this.globalQuizCh=this.realtime.channels.get(this.globalQuizChName),this.globalQuizCh.presence.enter({nickname:this.hostNickname,roomCode:this.myQuizRoomCode})},getRandomRoomId:function(){return"room-"+Math.random().toString(36).substr(2,8)},subscribeToRoomChEvents:function(){var e=this;this.myQuizRoomCh.subscribe("new-player",(function(t){e.handleNewPlayerEntered(t)})),this.myQuizRoomCh.subscribe("start-quiz-timer",(function(t){e.didHostStartGame=!0,e.timer=t.data.countDownSec})),this.myQuizRoomCh.subscribe("new-question",(function(t){e.handleNewQuestionReceived(t)})),this.myQuizRoomCh.subscribe("question-timer",(function(t){e.questionTimer=t.data.countDownSec,e.questionTimer<0&&(e.questionTimer=30)})),this.myQuizRoomCh.subscribe("correct-answer",(function(t){e.handleCorrectAnswerReceived(t)})),this.myQuizRoomCh.subscribe("live-stats-update",(function(t){e.numAnswered=t.data.numAnswered,e.numPlaying=t.data.numPlaying})),this.myQuizRoomCh.subscribe("full-leaderboard",(function(t){e.leaderboard=t.data.leaderboard}))},handleNewPlayerEntered:function(e){var t=e.data.newPlayerState,s=t.clientId,i=t.nickname,n=t.avatarColor,a=t.isHost;a||this.onlinePlayersArr.push({clientId:s,nickname:i,avatarColor:n,isHost:a})},handleNewQuestionReceived:function(e){this.showAnswer=!1,this.showQuestions=!0,this.newQuestionNumber=e.data.questionNumber,this.newQuestion=e.data.question,this.newChoices=e.data.choices,this.isLastQuestion=e.data.isLastQuestion,this.numAnswered=e.data.numAnswered,this.numPlaying=e.data.numPlaying,this.showImg=e.data.showImg,this.questionImgLink=e.data.imgLink},handleCorrectAnswerReceived:function(e){this.showAnswer=!0,this.newQuestionNumber==e.data.questionNumber&&(this.correctAnswerIndex=e.data.correctAnswerIndex),this.isLastQuestion&&(this.showFinalScreen=!0)},copyPlayerInviteLink:function(){var e=this;this.copyClicked=!0,this.copyBtnText="Copied!",setTimeout((function(){e.copyClicked=!1,e.copyBtnText="Copy shareable link"}),2e3),navigator.clipboard.writeText(this.playerLink)},startQuiz:function(){this.hostAdminCh.publish("start-quiz",{start:!0})},endQuizNow:function(){this.showFinalScreen=!0}},beforeDestroy:function(){this.myQuizRoomCh&&this.myQuizRoomCh.presence.leave(),this.questionTimer=30}},ce=ue,de=(s("6f9e"),Object(c["a"])(ce,ee,te,!1,null,"18d71a82",null)),he=de.exports,me={props:["realtime","ablyClientId"],data:function(){return{isTypeChosen:!1,headerLogo:"https://static.ably.dev/logo-h-white.svg?realtime-quiz-framework",quizType:"",windowWidth:window.innerWidth}},components:{CreateQuizRoom:he},methods:{setQuizType:function(e){this.isTypeChosen=!0,this.quizType=e},showHome:function(){this.isTypeChosen=!1}},computed:{isSmallWidth:function(){return this.windowWidth>480?(console.log(this.windowWidth),!1):(console.log(this.windowWidth),!0)}}},we=me,ye=(s("b560"),Object(c["a"])(we,K,X,!1,null,"2decba5d",null)),be=ye.exports,pe=[{path:"/play",component:Z},{path:"",component:be}];i["a"].config.productionTip=!1,i["a"].use(n["a"]);var ve=new n["a"]({routes:pe,mode:"history"});new i["a"]({router:ve,render:function(e){return e(h)}}).$mount("#app")},"6f9e":function(e,t,s){"use strict";var i=s("fc8a"),n=s.n(i);n.a},"85ec":function(e,t,s){},9408:function(e,t,s){"use strict";var i=s("fa04"),n=s.n(i);n.a},b176:function(e,t,s){},b344:function(e,t,s){},b560:function(e,t,s){"use strict";var i=s("e67b"),n=s.n(i);n.a},c0f7:function(e,t,s){},c8d2:function(e,t,s){"use strict";var i=s("38b1"),n=s.n(i);n.a},d819:function(e,t,s){"use strict";var i=s("f13c"),n=s.n(i);n.a},e67b:function(e,t,s){},f13c:function(e,t,s){},f709:function(e,t,s){"use strict";var i=s("c0f7"),n=s.n(i);n.a},fa04:function(e,t,s){},fc8a:function(e,t,s){}}); 2 | //# sourceMappingURL=app.db17cef0.js.map -------------------------------------------------------------------------------- /realtime-quiz/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "realtime-quiz", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "ably": "^1.2.2", 12 | "animate.css": "^4.1.1", 13 | "axios": "^0.20.0", 14 | "core-js": "^3.6.5", 15 | "express": "^4.17.1", 16 | "g-sheets-api": "^1.4.0", 17 | "path": "^0.12.7", 18 | "serve-static": "^1.14.1", 19 | "vue": "^2.6.11", 20 | "vue-router": "^3.4.3" 21 | }, 22 | "devDependencies": { 23 | "@vue/cli-plugin-babel": "~4.5.0", 24 | "@vue/cli-plugin-eslint": "~4.5.0", 25 | "@vue/cli-service": "~4.5.0", 26 | "babel-eslint": "^10.1.0", 27 | "eslint": "^6.7.2", 28 | "eslint-plugin-vue": "^6.2.2", 29 | "vue-template-compiler": "^2.6.11" 30 | }, 31 | "eslintConfig": { 32 | "root": true, 33 | "env": { 34 | "node": true 35 | }, 36 | "extends": [ 37 | "plugin:vue/essential", 38 | "eslint:recommended" 39 | ], 40 | "parserOptions": { 41 | "parser": "babel-eslint" 42 | }, 43 | "rules": {} 44 | }, 45 | "browserslist": [ 46 | "> 1%", 47 | "last 2 versions", 48 | "not dead" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /realtime-quiz/public/bg-shapes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/realtime-quiz/public/bg-shapes.png -------------------------------------------------------------------------------- /realtime-quiz/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/realtime-quiz/public/favicon.ico -------------------------------------------------------------------------------- /realtime-quiz/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 19 | 20 | Live trivia 21 | 67 | 68 | 69 | 76 |
77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /realtime-quiz/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 28 | 29 | 38 | -------------------------------------------------------------------------------- /realtime-quiz/src/assets/bg-shapes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/realtime-quiz/src/assets/bg-shapes.png -------------------------------------------------------------------------------- /realtime-quiz/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/realtime-quiz-framework/9ebb755cc69d84842cabcb1af42728d58d958fd1/realtime-quiz/src/assets/logo.png -------------------------------------------------------------------------------- /realtime-quiz/src/components/common/Answer.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | 42 | 72 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/common/Leaderboard.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | 66 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/common/LiveStats.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | 19 | 26 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/common/OnlinePlayers.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 31 | 32 | 83 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/common/Question.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 104 | 105 | 203 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/host/AdminPanel.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | 39 | 55 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/host/CreateQuizRoom.vue: -------------------------------------------------------------------------------- 1 | 154 | 155 | 380 | 381 | 491 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/host/HostHome.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 107 | 108 | 173 | -------------------------------------------------------------------------------- /realtime-quiz/src/components/player/PlayerHome.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 304 | 305 | 399 | -------------------------------------------------------------------------------- /realtime-quiz/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import App from './App.vue'; 4 | import { routes } from './routes'; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | Vue.use(VueRouter); 9 | const router = new VueRouter({ 10 | routes, 11 | mode: 'history' 12 | }); 13 | 14 | new Vue({ 15 | router, 16 | render: h => h(App) 17 | }).$mount('#app'); 18 | -------------------------------------------------------------------------------- /realtime-quiz/src/routes.js: -------------------------------------------------------------------------------- 1 | import PlayerHome from './components/player/PlayerHome.vue'; 2 | import HostHome from './components/host/HostHome.vue'; 3 | 4 | export const routes = [ 5 | { path: '/play', component: PlayerHome }, 6 | { path: '', component: HostHome } 7 | ]; 8 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const { Worker, isMainThread, threadId } = require('worker_threads'); 2 | 3 | const express = require('express'); 4 | const Ably = require('ably'); 5 | const envConfig = require('dotenv').config(); 6 | const serveStatic = require('serve-static'); 7 | const path = require('path'); 8 | 9 | const app = express(); 10 | const { ABLY_API_KEY } = envConfig.parsed; 11 | const globalQuizChName = 'main-quiz-thread'; 12 | 13 | console.log(envConfig, ABLY_API_KEY); 14 | 15 | let globalQuizChannel; 16 | const activeQuizRooms = {}; 17 | let totalPlayersThroughout = 0; 18 | 19 | const realtime = new Ably.Realtime({ 20 | key: ABLY_API_KEY, 21 | echoMessages: false 22 | }); 23 | 24 | app.use('/', serveStatic(path.join(__dirname, 'realtime-quiz/dist'))); 25 | 26 | app.get('/auth', (request, response) => { 27 | const tokenParams = { clientId: uniqueId() }; 28 | realtime.auth.createTokenRequest(tokenParams, function (err, tokenRequest) { 29 | if (err) { 30 | response 31 | .status(500) 32 | .send('Error requesting token: ' + JSON.stringify(err)); 33 | } else { 34 | response.setHeader('Content-Type', 'application/json'); 35 | response.setHeader('Access-Control-Allow-Origin', '*'); 36 | response.send(JSON.stringify(tokenRequest)); 37 | } 38 | }); 39 | }); 40 | 41 | const uniqueId = function () { 42 | return 'id-' + Math.random().toString(36).substr(2, 16); 43 | }; 44 | 45 | app.get('/', function (req, res) { 46 | res.sendFile(path.join(__dirname, 'realtime-quiz/dist/index.html')); 47 | }); 48 | 49 | app.get('/play', function (req, res) { 50 | let requestedRoomCode = req.query.quizCode; 51 | if (activeQuizRooms[requestedRoomCode].didQuizStart === true) { 52 | res.sendFile(path.join(__dirname, 'realtime-quiz/dist/index.html')); 53 | } else { 54 | res.sendFile(path.join(__dirname, 'realtime-quiz/dist/index.html')); 55 | } 56 | }); 57 | 58 | app.get('/checkRoomStatus', function (req, res) { 59 | res.setHeader('Access-Control-Allow-Origin', '*'); 60 | let requestedRoomCode = req.query.quizCode; 61 | res.send({ 62 | isRoomClosed: activeQuizRooms[requestedRoomCode] 63 | ? activeQuizRooms[requestedRoomCode].didQuizStart 64 | : true 65 | }); 66 | }); 67 | 68 | const listener = app.listen(process.env.PORT || 8082, () => { 69 | console.log('Your app is listening on port ' + listener.address().port); 70 | }); 71 | 72 | realtime.connection.once('connected', () => { 73 | globalQuizChannel = realtime.channels.get(globalQuizChName); 74 | globalQuizChannel.presence.subscribe('enter', (player) => { 75 | console.log('new quiz host', player.clientId); 76 | generateNewQuizRoom( 77 | player.data.nickname, 78 | player.data.roomCode, 79 | player.clientId 80 | ); 81 | }); 82 | }); 83 | 84 | function generateNewQuizRoom(hostNickname, hostRoomCode, hostClientId) { 85 | if (isMainThread) { 86 | const worker = new Worker('./quiz-room-server.js', { 87 | workerData: { 88 | hostNickname: hostNickname, 89 | hostRoomCode: hostRoomCode, 90 | hostClientId: hostClientId 91 | } 92 | }); 93 | console.log(`CREATING NEW THREAD WITH ID ${threadId}`); 94 | worker.on('error', (error) => { 95 | console.log(`WORKER EXITED DUE TO AN ERROR ${error}`); 96 | }); 97 | worker.on('message', (msg) => { 98 | if (msg.roomCode && !msg.killWorker) { 99 | activeQuizRooms[msg.roomCode] = { 100 | roomCode: msg.roomCode, 101 | totalPlayers: msg.totalPlayers, 102 | didQuizStart: msg.didQuizStart 103 | }; 104 | totalPlayersThroughout += totalPlayersThroughout; 105 | } else if (msg.roomCode && msg.killWorker) { 106 | totalPlayersThroughout -= msg.totalPlayers; 107 | delete activeQuizRooms[msg.roomCode]; 108 | } else { 109 | activeQuizRooms[msg.roomCode].didQuizStart = msg.didQuizStart; 110 | console.log('Main knows started'); 111 | } 112 | }); 113 | 114 | worker.on('exit', (code) => { 115 | console.log(`WORKER EXITED WITH THREAD ID ${threadId}`); 116 | if (code !== 0) { 117 | console.log(`WORKER EXITED DUE TO AN ERROR WITH CODE ${code}`); 118 | } 119 | }); 120 | } 121 | } 122 | --------------------------------------------------------------------------------