├── .gitignore ├── .gitpod.yml ├── .vscode └── settings.json ├── README.md ├── api ├── .env.sample ├── api.py ├── database │ ├── dal.py │ └── dbsession.py ├── gameStatus.py ├── inmemory │ └── dal.py ├── messaging.py ├── pulsarTools.py ├── requirements.txt ├── settings.py └── utils.py ├── astra.json ├── client ├── package-lock.json ├── package.json ├── public │ ├── astra-streaming-stacked-pos.png │ ├── brick.svg │ ├── food.svg │ ├── hearts.svg │ ├── index.html │ ├── lyco_other.svg │ └── lyco_self.svg └── src │ ├── App.css │ ├── App.js │ ├── components │ ├── ChatArea.js │ ├── GameArea.js │ ├── GameField.js │ ├── GameInputs.js │ └── PlayerForm.js │ ├── index.css │ ├── index.js │ ├── settings.js │ └── utils │ ├── guessAPILocation.js │ ├── messages.js │ ├── names.js │ └── playerMaps.js ├── images ├── Theridion_grallator.png ├── astra-create-token.gif ├── astra-token.png ├── astra_bundle.png ├── astra_create_db.gif ├── astra_create_streaming_topic_v2.gif ├── astra_signup.gif ├── cql_console.gif ├── create_astra_button.png ├── create_database_button.png ├── drag-and-drop-bundle.png ├── drapetisca_2.png ├── drapetisca_3_v2.png ├── drapetisca_homework.png ├── drapetisca_intro_v2.png ├── eavesdrop_streaming.gif ├── gitpod_view.png ├── open_in_gitpod_button.svg ├── orgsettings.png ├── ref_code_1.png ├── streaming-workshop.png ├── streaming_secrets.png └── try-me-demo-video-thumbnail.png └── slides └── DataStaxDevs-workshop-Build_a_Multiplayer_Game_with_Streaming.pdf /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | __pycache__ 3 | node_modules 4 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: open-readme 3 | command: gp open README.md 4 | - name: setup-api 5 | before: | 6 | cd /workspace/workshop-streaming-game/api 7 | python -m pip install --upgrade pip 8 | pip install -r requirements.txt 9 | command: | 10 | cd /workspace/workshop-streaming-game/api 11 | echo -e "\n\n*** API READY TO START***\n" 12 | - name: setup-client 13 | before: | 14 | cd /workspace/workshop-streaming-game/client 15 | nvm install 16.11.1 16 | npm install -g npm@latest 17 | npm install astra-setup 18 | npm install 19 | command: | 20 | cd /workspace/workshop-streaming-game/client 21 | echo -e "\n\n*** CLIENT READY TO START***\n" 22 | github: 23 | prebuilds: 24 | master: true 25 | branches: true 26 | pullRequests: true 27 | pullRequestsFromForks: false 28 | addCheck: true 29 | addComment: false 30 | addBadge: true 31 | addLabel: false 32 | ports: 33 | - port: 3000 34 | onOpen: open-preview 35 | visibility: public 36 | - port: 8000 37 | onOpen: ignore 38 | visibility: public 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.enablePreviewFromCodeNavigation": true, 3 | "workbench.editor.enablePreviewFromQuickOpen": true, 4 | "workbench.editor.enablePreview": true, 5 | "workbench.editorAssociations": { 6 | "*.md": "vscode.markdown.preview.editor" 7 | } 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Drapetisca: a multiplayer online game with Astra Streaming and Websockets 3 | 4 | [![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/datastaxdevs/workshop-streaming-game) 5 | [![License Apache2](https://img.shields.io/hexpm/l/plug.svg)](http://www.apache.org/licenses/LICENSE-2.0) 6 | [![Discord](https://img.shields.io/discord/685554030159593522)](https://discord.com/widget?id=685554030159593522&theme=dark) 7 | 8 | Time: *50 minutes*. Difficulty: *Intermediate*. [Start Building!](#lets-start) 9 | 10 | A simple multiplayer online game featuring 11 | * Astra Streaming (a messaging system in the cloud, built on top of Apache Pulsar) 12 | * Astra DB (a Database-as-a-service built on Apache Cassandra) 13 | * WebSockets 14 | * React.js for the front-end 15 | * the Python FastAPI framework for the back-end 16 | 17 | 18 | 19 | ![Drapetisca screenshot](images/drapetisca_intro_v2.png) 20 | 21 | ## Objectives 22 | * Understand the architecture of a streaming-based application 23 | * Learn how Apache Pulsar works 24 | * See the interplay between streaming and persistent storage (a.k.a. database) 25 | * Learn about Websockets on client- and server-side 26 | * Understand how a FastAPI server can bridge Pulsar topics and WebSockets 27 | * Understand the structure of a Websocket React.js application 28 | * **get your very own online gaming platform to share with your friends!** 29 | 30 | ## Frequently asked questions 31 | 32 | - *Can I run the workshop on my computer?* 33 | 34 | > You don't have to, it's all already in the cloud! But there is nothing preventing you from running the workshop on your own machine. 35 | > If you do so, you will need 36 | > * git installed on your local system 37 | > * [node 15 and npm 7 or later](https://www.whitesourcesoftware.com/free-developer-tools/blog/update-node-js/) 38 | > * [Python v3.8+ installed on your local system](https://www.python.org/downloads/) 39 | > 40 | > In this readme, we try to provide instructions for local development as well - but keep in mind that 41 | > the main focus is development on Gitpod, hence **We can't guarantee live support** about local development 42 | > in order to keep on track with the schedule. However, we will do our best to give you the info you need to succeed. 43 | 44 | - *What other prerequisites are there?* 45 | > * You will need a GitHub account 46 | > * You will also need an Astra account: don't worry, we'll work through that in the following 47 | 48 | - *Do I need to pay for anything for this workshop?* 49 | > * **No.** All tools and services we provide here are FREE. 50 | 51 | - *Will I get a certificate if I attend this workshop?* 52 | 53 | > Attending the session is not enough. You need to complete the homeworks detailed below and you will get a nice participation certificate a.k.a. badge. 54 | 55 | - *Why "Drapetisca"?* 56 | 57 | > _Drapetisca socialis_, known as "invisible spider", is a very small and hard-to-notice spider found throughout Europe. 58 | > Since this is a multiplayer game that lets players have social interactions in the play area, why not choose a spider 59 | > with "socialis" in its name? 60 | 61 | ## Materials for the Session 62 | 63 | It doesn't matter if you join our workshop live or you prefer to work at your own pace, 64 | we have you covered. In this repository, you'll find everything you need for this workshop: 65 | 66 | - [Workshop Video](https://youtu.be/jfOBPlcd9eA) 67 | - [Slide deck](slides/DataStaxDevs-workshop-Build_a_Multiplayer_Game_with_Streaming.pdf) 68 | - [Discord chat](https://dtsx.io/discord) 69 | - [Questions and Answers](https://community.datastax.com/) 70 | 71 | ## Homework 72 | 73 | 74 | 75 | Don't forget to complete your assignment and get your verified skill badge! Finish and submit your homework! 76 | 77 | 1. Complete the practice steps as described below until you have your own app running in Gitpod. 78 | 2. Now roll up your sleeves and modify the code in two ways: (1) we want the API to send a greeting to each new player in the chat box, and (2) we want the player names in the game area to match the icon color. _Please read the detailed guidance found [below](#7-homework-instructions)_. 79 | 3. Take a SCREENSHOT of the running app modified this way. _Note: you will have to restart the API and reload the client to see all changes!_ 80 | 4. Submit your homework [here](https://dtsx.io/streaming-game-homework). 81 | 82 | That's it, you are done! Expect an email in a few days! 83 | 84 | # Let's start 85 | 86 | ## Table of contents 87 | 88 | 1. [Create your Astra Streaming instance](#1-create-your-astra-streaming-instance) 89 | 2. [Create your Astra DB instance](#2-create-your-astra-db-instance) 90 | 3. [Load the project into Gitpod](#3-load-the-project-into-gitpod) 91 | 4. [Set up/start the API](#4-api-setup) 92 | 5. [Set up/start the client](#5-client-setup) 93 | 6. [Play!](#6-play-the-game) 94 | 7. [Homework instructions](#7-homework-instructions) 95 | 8. [Selected topics](#8-selected-topics) 96 | 97 | ## Astra setup 98 | 99 | ### 1. Create your Astra Streaming instance 100 | 101 | _**`Astra Streaming`** is the simplest way to get a streaming infrastructure based on Apache Pulsar 102 | with zero operations at all - just push the button and get your streaming. 103 | No credit card required - with the **free tier** comes a generous monthly-renewed credit for you to use._ 104 | 105 | _**`Astra Streaming`** is tightly integrated with `Astra DB`, the database-as-a-service 106 | used in most of our workshops (see below, we will use it momentarily). 107 | **If you already have an Astra account for Astra DB, you can use that 108 | one in the following (and jump to "Create streaming" below)!**_ 109 | 110 | For more information on Astra Streaming, look at [the docs](https://docs.datastax.com/en/astra-streaming/docs/). 111 | For more information on Apache Pulsar, here is [the documentation](https://pulsar.apache.org/docs/en/concepts-overview/). 112 | 113 | #### 1a. Register 114 | 115 | Register and sign in to Astra at `astra.datastax.com` by clicking this button (better in a new tab with Ctrl-click or right-click): 116 | 117 | 118 | 119 | _you can use your `Github`, `Google` accounts or register with an `email`. 120 | Choose a password with minimum 8 characters, containing upper and lowercase letters, at least one number and special character. 121 | You may be asked to verify your email, so make sure you have access to it._ 122 | 123 |
Show me the steps 124 | 125 |
126 | 127 | #### 1b. Create streaming 128 | 129 | Once registered and logged in, you will be able to create a new Astra Streaming topic: it will convey all messages for this app. 130 | 131 | You can find the instructions in [this wiki](https://github.com/datastaxdevs/awesome-astra/wiki/Create-an-AstraStreaming-Topic): in our case, the parameters to use are: 132 | 133 | - tenant name: `gameserver-` (you have to make it unique, so attach a suffix of your choice) 134 | - namespace: `default` (we will NOT need to create a new one) 135 | - topic name: `worldupdates` (persistent=yes, partitioned=no) 136 | 137 | > Note: technically you can name your namespace and topic anything you want - but then you have to make sure 138 | > the environment settings for your API code are changed accordingly (see later). 139 | 140 |
Show me the steps 141 | 142 |
143 | 144 | #### 1c. Retrieve streaming connection parameters 145 | 146 | While you are at it, you should get two pieces of information needed later to connect to the topic from the API code. Those are the "Broker Service URL" and the "Token", 147 | which can be obtained as described in [this wiki article](https://github.com/datastaxdevs/awesome-astra/wiki/Create-an-AstraStreaming-Topic#-step-4-retrieve-the-broker-url). 148 | 149 |
Show me how to get the topic connection parameters 150 | 151 |
152 | 153 | > The service URL looks something like `pulsar+ssl://pulsar-[...].streaming.datastax.com:6651`, 154 | while the token is a very long string such as `eyJhbGci [...] cpNpX_qN68Q`. 155 | > **The token is a secret string and you should keep it for yourself!** 156 | 157 | ### 2. Create your Astra DB instance 158 | 159 | Besides the streaming platform, you'll also need a database for persistence of some 160 | game data (the server-side representation of the "game world"). 161 | 162 | Correspondingly, you will need some connection parameters and secrets in order 163 | to later be able to access the database. 164 | 165 | #### 2a. Create the database 166 | 167 | _**`ASTRA DB`** is the simplest way to run Cassandra with zero operations at all - just push the button and get your cluster. No credit card required, $25.00 USD credit every month, roughly 20M read/write operations and 80GB storage monthly - sufficient to run small production workloads._ 168 | 169 | You will now create a database with a keyspace in it (a _keyspace_ can contain _tables_. 170 | Today's application needs just a single table: it will be created for you the first time you 171 | will launch it, so don't worry too much). 172 | 173 | 174 | 175 | To create the database, locate the "Create database" button on the navigation bar on the left of the Astra UI, click on it and fill the required 176 | values: 177 | 178 | - **For the database name** - use `workshops`. While Astra DB allows you to fill in these fields with values of your own choosing, please follow our recommendations to ensure the application runs properly. 179 | 180 | - **For the keyspace name** - use `drapetisca`. Please stick to this name, it will make the following steps much easier (you have to customize here and there otherwise). In short: 181 | 182 | - **For provider and region**: Choose **GCP**, which is immediately available to a fresh account (AWS and Azure would have to be unlocked, _for free_, by contacting Support). Region is where your database will reside physically (choose one close to you or your users). 183 | 184 | - **Create the database**. Review all the fields to make sure they are as shown, and click the `Create Database` button. You will be on the **Free** plan. 185 | 186 | | Parameter | Value 187 | |---|---| 188 | | Database name | workshops | 189 | | Keyspace name | drapetisca | 190 | 191 | You will see your new database as `Pending` in the Dashboard; 192 | the status will change to `Active` when the database is ready. This will only take 2-3 minutes 193 | (you will also receive an email when it is ready). 194 | 195 |
Show me the how to create my Astra DB 196 | 197 | To create the database, please note that _the `db_name` and `ks_name` in the above image are just placeholders_. 198 |
199 | 200 | > _Note_: if you already have a `workshops` database, for instance from a previous workshop with us, you can simply create the keyspace with the `Add Keyspace` button in your Astra DB dashboard: the new keyspace will be available in few seconds. 201 | 202 | #### 2b. Create a DB Token 203 | 204 | You need to create a **DB token**, which the API will later use as credentials to access the DB. 205 | 206 | From the Astra DB UI, [create a token](https://docs.datastax.com/en/astra/docs/manage-application-tokens.html) 207 | with `Database Administrator` roles. 208 | 209 | 210 | 211 | - Locate the "Current Organization" menu in the top-left of the Astra UI and select `Organization Settings` 212 | - Go to `Token Management` 213 | - Pick the role `Database Administrator` on the select box 214 | - Click Generate token 215 | 216 |
Show me the Astra DB token creation 217 | 218 |
219 | 220 | > **Tip**: you can quickly get to the "Token Management" also through the "..." 221 | > menu next to the list of databases in your main Astra DB UI. 222 | 223 | The "token" is composed by three parts: 224 | 225 | - `Client ID`: it plays the role of _username_ to connect to Astra DB; 226 | - `Client Secret`: it plays the role of a _password_; 227 | - `Token` (proper): _not needed today_. It would be used as API key to access Astra via the API Gateway. 228 | 229 | > You can either copy and paste the values or download them as a CSV (you'll need the `Client ID` and `Client Secret`momentarily). _To copy the values you can click on the clipboard icons._ 230 | 231 |
Show me the generated Astra DB token 232 | 233 |
234 | 235 | > Make sure you download the CSV or copy the token values you need: once this page is closed, 236 | > you won't be able to see your token again for security reasons! (then again, you can always issue a new token). 237 | 238 | > **⚠️ Important** 239 | > ``` 240 | > The instructor will show token creation on screen, 241 | > but will then immediately destroy the token for security reasons. 242 | > ``` 243 | 244 | #### 2c. Download the DB Secure Connection Bundle 245 | 246 | There's a last missing piece needed for the application to successfully connect 247 | to Astra DB: the "secure connect bundle". You have to download it from the Astra UI 248 | and keep it ready for later usage. 249 | 250 | > The secure bundle, a zipfile containing certificates and server address information, 251 | > will have to be provided to the Cassandra driver when the connection is established 252 | > (see later steps). 253 | 254 | Go to the Astra DB UI, find the `workshops` database and click on it: 255 | 256 | 1. click on `Connect` tab; 257 | 2. click on `Connect using a driver` (any language will do); 258 | 3. click on the `Download Bundle` drop-down on the right; 259 | 4. finally click on `Secure Connect Bundle ()` to start the download. The bundle file should have a name such as `secure-connect-workshops.zip` and be around 12KB in size. _Note: make sure you "Save" the zipfile whole, without unzipping/opening it (as some browser might suggest you to do._ 260 | 261 |
Show me how to get the Astra DB bundle 262 | 263 |
264 | 265 | 266 | ## Configure and run the application 267 | 268 | ### 3. Load the project into Gitpod 269 | 270 | Development and running will be done within a Gitpod instance (more on that in a second). 271 | 272 | #### 3a. Open Gitpod 273 | 274 | To load the whole project (API + client) in your personal Gitpod workspace, please 275 | Ctrl-click (or right-click and open in new tab) on the following button: 276 | 277 | 278 | 279 | (You may have to authenticate with Github or other providers along the process). 280 | Then wait a couple of minutes for the installations to complete, at which point you 281 | will see a message such as `CLIENT/API READY TO START` in the Gitpod console. 282 | 283 |
Tell me what the Gitpod button does 284 | 285 | - An IDE is started on a containerized machine image in the cloud 286 | - there, this repo is cloned 287 | - some initialization scripts are run (in particular, dependencies get installed) 288 | - Gitpod offers a full IDE: you can work there, edit files, run commands in the console, use an internal browser, etc. 289 | 290 |
291 | 292 | > In case you prefer to work _on your local computer_, no fear! You can simply keep 293 | > a console open to run the React client (`cd client`) and another for the 294 | > Python API (`cd api`). For the former 295 | > you will have to `npm install` and for the latter (preferrably in a virtual environment 296 | > to keep things tidy and clean) you will have to install the required dependencies 297 | > e.g. with `pip install -r requirements.txt`. 298 | > (Mac users will also have to do a `brew install libpulsar` for the API to work.) 299 | > The rest of this readme will draw your 300 | > attention to the occasional differences between the Gitpod and the local routes, but 301 | > we'll generally assume that if you work locally you know what you are doing. Good luck! 302 | 303 | #### 3b. Gitpod interface 304 | 305 | This project is composed of two parts: client and API. For this reason, Gitpod 306 | is configured to spawn _three_ different consoles: the "default" one for 307 | general-purpose actions, an "api" console and a "client" console (these two 308 | will start in the `api` and `client` subdirectories for you). 309 | **You can switch between consoles by clicking on the items in the lower-right panels in your Gitpod**. 310 | 311 |
Show me a map of the Gitpod starting layout 312 | 313 | 314 | 315 | 1. File explorer 316 | 2. Editor 317 | 3. Panel for console(s) 318 | 4. Console switcher 319 |
320 | 321 | > Note: for your convenience, you find this very README open within the Gitpod 322 | > text editor. 323 | 324 | ### 4. API setup 325 | 326 | Before you can launch the API, you have to configure connection details to it: 327 | you will do it through the dotenv file `.env`. 328 | 329 | #### 4a. Streaming environment variables (`.env`, part I) 330 | 331 | You need to pass the streaming connection URL and streaming token to the API for 332 | it to be able to speak to the Streaming topic. To do so, first **go to the API console** 333 | and make sure you are in the `api` subdirectory. 334 | 335 | > The `pwd` command should output `/workspace/workshop-streaming-game/api`. 336 | 337 | > If you are working locally, make sure you are in the `/api` subdirectory 338 | > of the project for the following commands to work properly. Later anyway, 339 | > in order to have both the API and the client running, you will need two 340 | > consoles, one in each of the two `api` and `client` subdirectories. 341 | 342 | Then create a file `.env` by copying the `.env.sample` in the same directory, 343 | with the commands 344 | 345 | # In the 'api' subdirectory 346 | cp .env.sample .env 347 | gp open .env 348 | 349 | (the second line will simply open the `.env` file in the editor: you can also simply locate the file in Gitpod's file explorer and click on it). 350 | Fill the first lines in the file with the values found earlier 351 | on your Astra Streaming "Connect" tab (_keep the quotes in the file_): 352 | 353 | - `STREAMING_TENANT`: your very own tenant name as chosen earlier when creating the topic (step `1b`); it should be something like `gameserver-abc`. 354 | - `STREAMING_SERVICE_URL`: it looks similar to `pulsar+ssl://pulsar-aws-useast2.streaming.datastax.com:6651` 355 | - `ASTRA_STREAMING_TOKEN`: a very long string (about 500 random-looking chars), see step `1c`. You can copy it on the Astra UI without showing it. 356 | 357 | > Note: treat your token as a personal secret: do not share it, do not commit it to the repo, store it in a safe place! 358 | 359 | > Note: in case you gave a different namespace/name to your topic, update `.env` correspondingly. 360 | > If, moreover, you work locally you may have to check the `TRUST_CERTS` variable as well, depending 361 | > on your OS distribution. Look into the `.env` file for some suggestions. 362 | 363 | #### 4b. Upload the DB secure bundle (`.env`, part II) 364 | 365 | Remember the secure connect bundle you downloaded earlier from the Astra DB UI? 366 | It's time to upload it to Gitpod. 367 | 368 | > If you work locally, skip the upload and just be aware of the full path to it for what comes next in the `.env` file. 369 | 370 | Locate the file on your computer using the "finder/explorer". 371 | Drag and drop the bundle into the Gitpod explorer window: _make sure you drop it on the 372 | file explorer window in Gitpod._ 373 | 374 |
Show me how to drag-and-drop the bundle to Gitpod 375 | 376 |
377 | 378 | As a check, you may want to verify the file is available in the right location with: 379 | 380 | ls -lh /workspace/workshop-streaming-game/secure-connect-*.zip 381 | 382 | The output should tell you the exact path to the file (you can also make sure the file size is around 12KB 383 | while you are at it). 384 | 385 | **This exact path to the file must go to line `ASTRA_DB_SECURE_CONNECT_BUNDLE` of the `.env` file.** 386 | The line has been pre-filled for you already, but if the bundle has a different name or is at a 387 | different location (e.g. if you work locally, or your DB has another name), 388 | make sure you change the value accordingly. 389 | 390 | #### 4c. Database access secrets (`.env`, part III) 391 | 392 | With the secure bundle in place and set up in the `.env`, you can turn to the last missing 393 | piece: the secrets to access the DB. 394 | 395 | Insert the Astra DB `Client ID` and `Client Secret` you obtained earlier as parts of the "Astra DB Token" 396 | in the `.env` file (again, keep the quotes around the values): 397 | 398 | ASTRA_DB_USERNAME="tByuQfj..." 399 | ASTRA_DB_PASSWORD="lGzF5,L..." 400 | 401 | > In case your keyspace has a name other than `drapetisca`, check the `ASTRA_DB_KEYSPACE` in your `.env` as well. 402 | 403 | Congratulations: you should now have completed the `.env` setup! 404 | 405 | #### 4d. Start the API 406 | 407 | Make sure you are in the API console and in the `api` subdirectory. 408 | You can now **start the API**: 409 | 410 | # In the 'api' subdirectory 411 | uvicorn api:app 412 | 413 | You should see the API start and log some messages in the console, in particular 414 | 415 | INFO: Application startup complete. 416 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 417 | 418 | Congratulations: the API is up and is ready to accept client requests. 419 | Leave it running and turn your attention to the client. 420 | 421 | > Note: this is how you start the API in a development environment. To deploy 422 | > to production, you should set up a multi-process system service for `uvicorn` 423 | > with the `--workers` option and put the whole thing behind a 424 | > reverse proxy. _This is not covered here_. 425 | 426 | ### 5. Client setup 427 | 428 | Make sure you **go to the client console** for the following 429 | (to switch consoles, look at the lower-right panel in your Gitpod layout). 430 | You should be in the `client` project subdirectory (i.e. the `pwd` command should print `/workspace/workshop-streaming-game/client`). 431 | 432 | #### 5a. Install dependencies 433 | 434 | First ensure all required dependencies are installed: 435 | 436 | # In the 'client' subdirectory 437 | npm install 438 | 439 | > Note: the command would take a few minutes on a fresh directory; we secretly instructed Gitpod 440 | > to preinstall them just to save you some time in this step - still, we want 441 | > you to go through it. Obviously, if you are working on your local environment, 442 | > this will be slower. 443 | 444 | #### 5b. Start the client 445 | 446 | The client is ready to go! **Launch it** in development mode with: 447 | 448 | # In the 'client' subdirectory 449 | npm start 450 | 451 | Let's assume you are working within Gitpod, which wraps locally-exposed ports 452 | and makes them accessible through ordinary HTTPS domain names. 453 | As the client is available, Gitpod will automatically open it in its "simple browser", 454 | using a domain such as `https://3000-tan-swallow-2yz174hp.ws-eu17.gitpod.io`. 455 | This URL can be obtained also by typing, in the general-purpose Gitpod console, 456 | 457 | gp url 3000 458 | 459 | (3000 being the port number locally used by npm to serve the client). 460 | This will match the URL shown in the address bar of your simple browser. 461 | 462 | Note that you can also take this URL and open the application in a new tab, 463 | **which you are encouraged to do to use your full screen**. 464 | 465 | > Note: we set up Gitpod for this workshop so as to make this URL accessible by anyone, to allow you 466 | > to paste the link to your friends, thereby inviting them to your own game instance! 467 | 468 | > If you are running everything locally on your computer, instead, you can 469 | > open the client on `http://localhost:3000` and use the 470 | > default API location of `ws://localhost:8000` to enter the game. 471 | 472 | > Note: this is how you launch the client in development mode. For deploying 473 | > to production, you should first build the project and then serve it from 474 | > a static Web server. _This is not covered here_. 475 | 476 | ### 6. Play the game! 477 | 478 | We finally have all pieces in place: 479 | 480 | - an Astra Streaming topic; 481 | - an API bridging it to ... 482 | - ... a client ready to establish WebSocket connections. 483 | - (and an Astra DB instance acting as persistent storage to back the API) 484 | 485 | It is time to play! 486 | 487 | #### 6a. Enter the game 488 | 489 | Change your name if you desire (a spider name is drawn at random for you). 490 | You will also see that you are given a (read-only) unique player ID and that an API address 491 | is configured for the client to establish WebSocket connections to. 492 | 493 | > The API location points to the instance of the API running alongside the client: 494 | > you should generally not have to change it (but please notice the protocol is 495 | > either `ws` or `wss`, which stand for WebSocket and Secure WebSocket respectively). 496 | 497 | To enter the game, click the "Enter Game" button. 498 | 499 |
Show me the "Enter Game" form 500 | 501 |
502 | 503 | Well done: you are in the game. You should see your player appear in the arena! 504 | 505 | - To control your player, either use the on-screen arrow buttons or, after bringing the game field into focus, your keyboard's arrow keys; 506 | - you can use the in-game chat box on the left. 507 | 508 |
Show me the player after entering the game 509 | 510 |
511 | 512 | > **Note**: if you experience a laggy game, especially with several players at once, 513 | > it is probably due to the fact that you have no control over the physical location of your Gitpod instance: 514 | > it may have been deployed far from the database. Remember that in a real-life online game great care 515 | > is taken to keep all parts close to each other to keep latencies under control. 516 | > If you want to play the game nevertheless, you can set the `USE_IN_MEMORY_STORAGE` variable 517 | > to `"1"` in your `.env` and then stop-and-restart the API: this will replace usage of Astra DB 518 | > with an in-memory local store; it is a development-only solution, however, that would of course 519 | > not work were you to scale to several running instances of the API. 520 | 521 | Anything your player does is sent to the API through a WebSocket in the form of an "update message"; 522 | from there, it is published to the Astra Streaming topic (and persisted to the database). 523 | The API will then pick up the update 524 | and broadcast to all players, whose client will process it, eventually leading to a refresh of the game status 525 | on the front-end. All this happens in a near-real-time fashion at every action by every player. 526 | 527 | > Note that the game shows the last sent message and the last received messages for you to better inspect 528 | > the messaging pattern at play. 529 | 530 | #### 6b. Try to cheat 531 | 532 | Let's be honest: there's no multiplayer game without cheaters - at least, cheat attempts. 533 | So, for example, try to _walk beyond the boundaries of the play area_, to see what happens. 534 | Notice the "Position" caption on the left sidebar? If you keep an arrow key pressed 535 | long enough, you will sure be able to bring that position to an illegal value such as `(-1, 0)`. 536 | But as soon as you release the key, the position bounces back to a valid state. 537 | 538 | Here's the trick: this "position", shown in the client, is nothing more than a variable 539 | in the client's memory. Every update (including `(-1, 0)`) is sent to the API, which 540 | is the sole actor in charge of validation: an illegal value will be corrected and sent back 541 | to all clients (_consider the API has access to the game-world state persisted on DB 542 | and can handle collisions and the like_). 543 | In particular, your own client will adjust knowledge of its own position 544 | based on this feedback from the API - which is why you see the illegal value only briefly. 545 | 546 | All of this must happen asynchronously, as is the nature of the communication between client 547 | and API. There is a lesson here, which has been hard-earned by online game devs over the years: 548 | _never leave validity checks in the hand of the client_. 549 | 550 | > Remember the hordes of cheaters in ... er ... Diablo I ? 551 | 552 | **Implications on the architecture** 553 | 554 | Unfortunately such an all-server architecture is more complex to achieve. 555 | One has to introduce a "generation counter" to avoid accidentally triggering 556 | infinite loops of spurious player-position updates - you can see this 557 | ever-increasing generation counter (`generation`) if you inspect the 558 | player-position updates shown at the bottom of the application. 559 | 560 | In the client code, the crucial bit is to accept updates to your-own-position 561 | coming from the server, _only if they are recent enough_. For further inspection, 562 | have a look at: 563 | 564 | - API: usage of `validatePosition` at line 66 of `api.py`; 565 | - Client: condition on `generation` at line 109 of `App.js` before invoking `setPlayerX` and `setPlayerY`. 566 | 567 | > Note: in this architecture, we very much **want** to have a server between 568 | > clients and Pulsar topic, with the responsibility of performing validations. 569 | > Even more so in a complex game, where each client action (message) triggers 570 | > potentially several actions in the world. But we want to mention, in passing by, 571 | > that Pulsar also offers its own 572 | > [native WebSocket interface](https://pulsar.apache.org/docs/en/client-libraries-websocket/) 573 | > (and so does Astra Streaming), 574 | > for clients to directly connect to topics using that protocol. 575 | 576 | #### 6c. Bring your friends 577 | 578 | But wait ... this is a _multiplayer_ game, isn't it? So, go ahead and open a new 579 | browser tab, then enter the game as someone else. 580 | 581 | Hooray! As soon as you move around with this new player, 582 | you will see the whole architecture at work: 583 | 584 | 1. client sends updates on your player's position through the "player websocket"; 585 | 2. API checks game state on DB and validates this update, taking action if needed; 586 | 3. API (a) publishes the validated player update to the Astra Streaming topic and (b) persists new game-state to DB; 587 | 4. API receives back new messages by listening to this same Astra Streaming topic; 588 | 5. API broadcasts updates on any player to all connected clients through the "world websocket"; 589 | 6. at each such update, the client's game arena is adjusted (for all connected clients). 590 | 591 | What is really cool is that **you can give this URL to your friends** and have them 592 | enter your very own game! 593 | 594 | _Please do this and tell the world about how easy it is to build a multiplayer real-time 595 | game with Astra Streaming!_ 596 | 597 | #### 6d. Fun with the Streaming UI 598 | 599 | The Astra Streaming interface makes it possible to eavesdrop on the topic and 600 | observe the messages passing through it. This may prove very useful for 601 | debugging. 602 | 603 | In the Astra Streaming UI, head to the "Try Me" tab and make sure: 604 | 605 | - the namespace and the (producer, consumer) topics are set to the values used earlier; 606 | - connection type is "Read"; 607 | - read position is "Latest" 608 | 609 | Good, now click "Connect". 610 | 611 |
Show me the "Try Me" interface 612 | 613 |
614 | 615 |
A demo video of the "Try Me" feature (Youtube) 616 | 617 | 618 | 619 |
620 | 621 | You now have a privileged view over the messages flowing through the Streaming 622 | topic. Now try writing something in the Chat box: can you see the corresponding 623 | message in the Streaming UI? 624 | 625 | What kind of message do you see, instead, if you move you player? 626 | 627 | But wait, there's more: now you can **hack the system**! Indeed, this same interface lets you 628 | produce surreptitious messages into the topic ("Send" button on the Streaming UI). 629 | Try to insert a message such as: 630 | 631 | { 632 | "messageType": "chat", 633 | "payload": { 634 | "id": "000", 635 | "name": "Phantom!", 636 | "text": "Booo!" 637 | }, 638 | "playerID": "nonexistent" 639 | } 640 | 641 | and keep an eye on the chat box. 642 | 643 | Even better, try to inject a message such as _(you may have to adjust the `x`, `y` coordinates for this to be real fun)_: 644 | 645 | { 646 | "messageType": "brick", 647 | "payload": { 648 | "name": "phantom brick!", 649 | "x": 0, 650 | "y": 0 651 | } 652 | } 653 | 654 | what happens in the game UI when you to this? Can you walk to that spot? (Why?) 655 | 656 | > Also, have you noticed that the "Try Me" interface shows how each message you publish to the topic is echoed back to you? 657 | > This is done by the API logic and is part of the game design. 658 | 659 | Now, you just had a little fun: but, seriously speaking, this ability to manually intervene in the stream of messages makes for a valuable debugging tool. 660 | 661 | #### 6e. A quick look at the data on DB 662 | 663 | Any change to the game-world, either originated in the API or coming from 664 | a player (and then just validated at API level) is persisted on database. 665 | If you are curious, you can look at the raw data directly within the Astra DB UI. 666 | 667 | Each time the API starts, it will generate a new "game ID", under which all info 668 | pertaining to this particular game will be stored. In fact, `game_id` plays the 669 | role of 670 | [partition key](https://docs.datastax.com/en/astra-cql/doc/cql/ddl/dataModelingApproach.html) in the underlying `drapetisca.objects_by_game_id` table. 671 | 672 | > The topic of data storage and data modeling in Cassandra is huge and we won't 673 | > do it justice here. Just bear with us to see the game data, and if you want 674 | > to know more you can start from [Data modeling by example](https://www.datastax.com/learn/data-modeling-by-example) and [What is Cassandra?](https://www.datastax.com/cassandra). You will embark on a long and exciting journey! 675 | 676 | Locate the "CQL Console" tab for the `workshops` database in your Astra DB dashboard 677 | and click on it. An interactive shell will be spawned for you, to type the following commands: 678 | 679 | ```sql 680 | USE drapetisca ; 681 | SELECT * FROM objects_by_game_id ; 682 | ``` 683 | 684 | You should see several lines in the output, corresponding to the objects present in the game(s) 685 | and their properties. 686 | 687 | > If you already started several games (e.g. by hitting Ctrl-C and restarting `uvicorn` in the API console), notice that the info for each of them is neatly grouped by the value of the `game_id` column. 688 | 689 |
Show me the game data in the Astra DB CQL Console 690 | 691 |
692 | 693 | ### 7. Homework instructions 694 | 695 | Here are some more details on how to do the homework. We require two modifications 696 | to the code, one on the API and one on the client. Once you change both, and restart, 697 | you will be able to take a screenshot showing the new game appearance and submit 698 | it to us! 699 | 700 | #### 7a. Server side 701 | 702 | We want a greeting message to be sent from the API to a new client right after 703 | they join. To do so, the `api.py` already imports a function `makeWelcomeUpdate` 704 | that returns a "chat message" ready to be sent through the WebSocket. 705 | You may also want to make use of variable `newPlayerName` that is made available 706 | in the API code. 707 | 708 | **You should add a line in the function `playerWSRoute` that creates the welcome 709 | chat message and sends it to the WebSocket**. _Suggestion: this is really not 710 | so different from the geometry update any new client receives upon connecting._ 711 | 712 | #### 7b. Client side 713 | 714 | We want the player names on the game field to have the same color as the player 715 | icons instead of always dark grey as they are now. If you look into `GameField.js`, 716 | you'll notice that the SVG `text` element currently has a class name `"player-name"`. 717 | 718 | **Make it so that players (self/other) use different class names in their `text` 719 | element and have a color matching their icon**. _Suggestion: the right class name 720 | is already calculated a few lines above for you to use (you can check in `App.css` as well)_. 721 | 722 | #### 7c. Restart, test and take a screenshot 723 | 724 | Remember to stop and restart the API: go to its console, hit Ctrl-C and 725 | re-run `uvicorn api:app` to do so. All current WebSocket connections will 726 | be lost. 727 | 728 | The client is running in development mode, so it should pick up any changes 729 | live and be immediately ready to serve the new version: reloading the app page 730 | (and re-entering the game) should be enough. 731 | 732 | At that point you will be playing the improved game: homework completed! 733 | 734 |
Show me the new features in the game 735 | 736 |
737 | 738 | ### 8. Selected topics 739 | 740 | Let us briefly mention some specific parts of the implementation of this game. 741 | 742 | #### 8a. WebSockets and React 743 | 744 | API and client communicate through WebSockets: in this way, we have a connection 745 | that is kept open for a long time and allows for a fast exchange of messages 746 | without the overhead of establishing a new connection for each message; 747 | moreover, this allows the server to push data without waiting for the client 748 | to initiate the exchange (as in the obsolete technique of client-side polling). 749 | WebSockets follow a robust and standardized [protocol](https://datatracker.ietf.org/doc/html/rfc6455) 750 | which makes it possible for us developers to concentrate on our game logic 751 | instead of having to worry about the communication internals. 752 | 753 | In particular, this game uses two WebSockets: 754 | 755 | - a "player" one for bidirectional client/server data transmission in a direct fashion; 756 | - a "world" one where the API route all messages picked up by the streaming topic. Most game status updates go through this route (with the exception of those directed at an individual player). 757 | 758 | You can find the corresponding variables `pws` and `wws` in the client code, respectively. 759 | 760 | In Javascript, one _subscribes to an event_ on an open WebSocket, providing 761 | a callback function with `webSocket.onmessage = `. But beware: 762 | if you simply try to read a React state (such as `generation`) from within 763 | the callback, you will generally get a stale value, _corresponding to the 764 | state when the subscription was made_. In practice, the state variable 765 | is "closed over". To overcome this problem, and be able to access the latest 766 | updated value of the state, we declare a React "reference" with `useRef` 767 | and, after linking it to the state we want to read, we use this reference 768 | within the callback to dynamically retrieve the current value of the state. 769 | 770 |
Look at lines 49-50 and then 103 of `App.js`, for example. 771 | 772 |
773 | 774 | #### 8b. FastAPI 775 | 776 | This game's architecture involves a server. Indeed, we would not be able 777 | to implement it using only serverless functions, at least not in a way similar 778 | to what you see here, because of statefulness. We need a server able to sustain 779 | the WebSocket connections for a long time, on one side, and to maintain 780 | long-running subscriptions to the Pulsar topics on the other side. 781 | 782 | We chose to create the API in Python, and to use 783 | [FastAPI](https://fastapi.tiangolo.com/), for a couple of very valid 784 | reasons. FastAPI integrates very well with the async/await features of modern 785 | Python, which results in a more efficient handling of concurrency. Moreover, 786 | it supports WebSockets (through its integration with 787 | [Starlette](https://www.starlette.io/)) with a natural syntax that reduces 788 | the need for boilerplate code to near zero. 789 | 790 | > There are other cool features of FastAPI (besides its namesake high performance), 791 | > which we do not employ here but make it a prime choice. There is a clever 792 | > mechanism to handle route dependencies aimed at reducing the amount of "boring" 793 | > code one has to write; and there is native support for those small tasks 794 | > that sometimes you have to trigger asynchronously right after a request completes, 795 | > those that in other frameworks would have required to set up machinery like Celery. 796 | 797 | Have a look at `api.py` to see how a WebSocket connection is handled: decorating 798 | a certain function with `@app.websocket(...)` is almost everything you need to 799 | get started. One of the arguments of the function will represent the WebSocket 800 | itself, supporting methods such as `send_text` and `receive_text`. Each 801 | active WebSocket connection to the server will spawn an invocation of this 802 | function, which will run as long as the connection is maintained: the support 803 | for async/await guarantees that these concurrent executions of the 804 | WebSocket function will be scheduled efficiently with no deadlocks. 805 | 806 | #### 8c. SVG Tricks 807 | 808 | One of the React components in the client code is the `GameField`, which 809 | represents an area where we draw the players. This is a single large SVG 810 | element, whose child elements are managed with the usual `jsx` syntax. 811 | 812 | A technique that proved useful in this game is that of defining, and then 813 | re-using multiple times, "patterns", basically as sprites. If you look 814 | at the `GameField.js` render code, you notice that the SVG first declares 815 | some `pattern` elements with certain `id`s (such as `lyco_other`). 816 | These patterns are then employed in various parts of the SVG to "fill" 817 | rectangles, which effectively makes it possible to use them as repeated sprites: 818 | 819 | 820 | 821 | ## The End 822 | 823 | Congratulations, you made it to the end! Please share the URL of your game with 824 | your friends: who does not love a little cozy spider gathering? 825 | 826 | _(Please notice that after some inactivity your Gitpod instance will be hibernated: 827 | you will need to re-start client and server to be able to play again.)_ 828 | 829 | Don't forget to complete and submit your [homework](#homework) to claim 830 | your badge, and see you next time! 831 | 832 | > DataStax Developers 833 | 834 | ![Theridion grallator](images/Theridion_grallator.png) 835 | -------------------------------------------------------------------------------- /api/.env.sample: -------------------------------------------------------------------------------- 1 | # 2 | # Astra Streaming part: 3 | # 4 | 5 | STREAMING_TENANT="" 6 | STREAMING_SERVICE_URL="" 7 | ASTRA_STREAMING_TOKEN="" 8 | 9 | # Ubuntu/Debian: 10 | TRUST_CERTS="/etc/ssl/certs/ca-certificates.crt" 11 | # RHEL/CentOS: 12 | # TRUST_CERTS='/etc/ssl/certs/ca-bundle.crt' 13 | # Mac: 14 | # TRUST_CERTS="/etc/ssl/cert.pem" 15 | 16 | STREAMING_NAMESPACE="default" 17 | STREAMING_TOPIC="worldupdates" 18 | 19 | # 20 | # Astra DB part: 21 | # 22 | 23 | ASTRA_DB_SECURE_CONNECT_BUNDLE="/workspace/workshop-streaming-game/secure-connect-workshops.zip" 24 | ASTRA_DB_USERNAME="" 25 | ASTRA_DB_PASSWORD="" 26 | ASTRA_DB_KEYSPACE = "drapetisca" 27 | 28 | 29 | # "Debug flag": replace Astra DB with in-memory storage (replace with "1" if desired) 30 | USE_IN_MEMORY_STORAGE="0" 31 | -------------------------------------------------------------------------------- /api/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | api.py 3 | Run with: 4 | uvicorn api:app 5 | """ 6 | 7 | import asyncio 8 | import json 9 | from uuid import uuid4 10 | 11 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect 12 | from fastapi.responses import HTMLResponse 13 | 14 | from pulsarTools import (getPulsarClient, getConsumer, getProducer, 15 | receiveOrNone) 16 | from utils import dictMerge 17 | from messaging import (validatePosition, makeLeavingUpdate, makeGeometryUpdate, 18 | makeWelcomeUpdate, makeEnteringPositionUpdate, 19 | makeCoordPair, pickFoodPositions, makeFoodUpdate, 20 | makeServerChatUpdate) 21 | from gameStatus import (storeGamePlayerStatus, retrieveActiveGameItems, 22 | retrieveGamePlayerStatus, storeGameInactivePlayer, 23 | storeGameActivePlayer, retrieveFieldOccupancy, 24 | storeGamePlayerPosition, storeFoodItemStatus, 25 | layBricks, layFood) 26 | 27 | from settings import (HALF_SIZE_X, HALF_SIZE_Y, RECEIVE_TIMEOUTS_MS, 28 | SLEEP_BETWEEN_READS_MS, BRICK_FRACTION, NUM_FOOD_ITEMS) 29 | 30 | app = FastAPI() 31 | gameID = str(uuid4()) 32 | 33 | 34 | # a one-off gamefield initialization routine 35 | layBricks(gameID, HALF_SIZE_X, HALF_SIZE_Y, BRICK_FRACTION) 36 | layFood(gameID, HALF_SIZE_X, HALF_SIZE_Y, NUM_FOOD_ITEMS) 37 | 38 | @app.websocket("/ws/world/{client_id}") 39 | async def worldWSRoute(worldWS: WebSocket, client_id: str): 40 | await worldWS.accept() 41 | # 42 | pulsarClient = getPulsarClient() 43 | pulsarConsumer = getConsumer(client_id, pulsarClient) 44 | # 45 | try: 46 | # we start picking messages from the Pulsar 'bus' 47 | # and routing them to this client 48 | while True: 49 | worldUpdateMsg = receiveOrNone(pulsarConsumer, RECEIVE_TIMEOUTS_MS) 50 | if worldUpdateMsg is not None: 51 | # We forward any update from Pulsar into the 'world' websocket 52 | # for all clients out there: 53 | await worldWS.send_text(worldUpdateMsg.data().decode()) 54 | pulsarConsumer.acknowledge(worldUpdateMsg) 55 | await asyncio.sleep(SLEEP_BETWEEN_READS_MS / 1000) 56 | except WebSocketDisconnect: 57 | pulsarConsumer.close() 58 | 59 | 60 | @app.websocket("/ws/player/{client_id}") 61 | async def playerWSRoute(playerWS: WebSocket, client_id: str): 62 | await playerWS.accept() 63 | # 64 | pulsarClient = getPulsarClient() 65 | pulsarProducer = getProducer(pulsarClient) 66 | # 67 | while True: 68 | try: 69 | # Any update from a player coming through the 'player' websocket 70 | updateMsgBlob = await playerWS.receive_text() 71 | # we unpack and inject playerID to the incoming message 72 | updateMsg = dictMerge( 73 | json.loads(updateMsgBlob), 74 | {'playerID': client_id}, 75 | ) 76 | if updateMsg['messageType'] == 'player': 77 | # if it's a player position update, retrieve gamefield status... 78 | fieldOccupancy = retrieveFieldOccupancy(gameID) 79 | # ... and last position for this player (if any) 80 | prevUpdate = retrieveGamePlayerStatus(gameID, client_id) 81 | # ... update is then validated ... 82 | playerUpdate, caughtFoodItem = validatePosition( 83 | updateMsg, 84 | HALF_SIZE_X, 85 | HALF_SIZE_Y, 86 | fieldOccupancy, 87 | prevUpdate 88 | ) 89 | # We deal with caught food, if any 90 | if caughtFoodItem is not None: 91 | caughtFoodID = caughtFoodItem['object_id'] 92 | caughtFoodName = caughtFoodItem['name'] 93 | # relocate the food item 94 | newFPos = list(pickFoodPositions( 95 | 2*HALF_SIZE_X-1, 96 | 2*HALF_SIZE_Y-1, 97 | 1, 98 | fieldOccupancy, 99 | ))[0] 100 | # create the food update 101 | foodUpdate = makeFoodUpdate( 102 | caughtFoodID, 103 | caughtFoodName, 104 | newFPos[0], 105 | newFPos[1], 106 | ) 107 | # persist new location to DB 108 | storeFoodItemStatus(gameID, foodUpdate) 109 | # congratulate catcher 110 | congratMessage = makeServerChatUpdate(client_id, 'Good catch!') 111 | await playerWS.send_text(json.dumps(congratMessage)) 112 | # broadcast (to pulsar) new food update 113 | pulsarProducer.send((json.dumps(foodUpdate)).encode('utf-8')) 114 | # 115 | # ... persisted in the server-side status... 116 | storeGamePlayerStatus(gameID, playerUpdate) 117 | # ... and finally sent to Pulsar 118 | pulsarProducer.send((json.dumps(playerUpdate)).encode('utf-8')) 119 | elif updateMsg['messageType'] == 'leaving': 120 | # Player is leaving the game: we update our state to reflect this 121 | storeGameInactivePlayer(gameID, client_id) 122 | # ...but also broadcast this information to all players 123 | pulsarProducer.send((json.dumps(updateMsg)).encode('utf-8')) 124 | elif updateMsg['messageType'] == 'entering': 125 | # A new player announced they're entering and is asking for data 126 | newPlayerName = updateMsg['payload']['name'] 127 | # first we tell this client how the game-field looks like 128 | geomUpdate = makeGeometryUpdate(HALF_SIZE_X, HALF_SIZE_Y) 129 | # Note: this message is built here (i.e. no Pulsar involved) 130 | # and directly sent to a single client, the one who just connected: 131 | await playerWS.send_text(json.dumps(geomUpdate)) 132 | # we brief the newcomer on all players on the stage 133 | for ops in retrieveActiveGameItems(gameID, {client_id}): 134 | await playerWS.send_text(json.dumps(ops)) 135 | # do we have a previously-stored position/status for this same player? 136 | plStatus = retrieveGamePlayerStatus(gameID, client_id) 137 | # we check the field occupancy for next step ... 138 | fieldOccupancy = retrieveFieldOccupancy(gameID) 139 | playerPrevCoords = makeCoordPair(plStatus) 140 | if plStatus is not None and playerPrevCoords not in fieldOccupancy: 141 | renamedPlStatus = dictMerge( 142 | {'payload': {'name': newPlayerName}}, 143 | plStatus, 144 | ) 145 | await playerWS.send_text(json.dumps(renamedPlStatus)) 146 | # returning players: we want to route their return to pulsar as well 147 | pulsarProducer.send((json.dumps(renamedPlStatus)).encode('utf-8')) 148 | # and we also want to mark them as active 149 | storeGameActivePlayer(gameID, client_id) 150 | else: 151 | firstPos = makeEnteringPositionUpdate( 152 | client_id=client_id, 153 | client_name=newPlayerName, 154 | halfX=HALF_SIZE_X, 155 | halfY=HALF_SIZE_Y, 156 | occupancyMap=fieldOccupancy, 157 | ) 158 | await playerWS.send_text(json.dumps(firstPos)) 159 | storeGamePlayerPosition(gameID, client_id, True, *makeCoordPair(firstPos)) 160 | else: 161 | # other types of message undergo no validation whatsoever: 162 | # we simply add the player ID to the message and publish 163 | pulsarProducer.send((json.dumps(updateMsg)).encode('utf-8')) 164 | except WebSocketDisconnect: 165 | # In this case we issue the "goodbye message" (i.e. null position) 166 | # on behalf of the client 167 | leavingUpdate = makeLeavingUpdate(client_id) 168 | # ... we store the disappearance of the player 169 | storeGameInactivePlayer(gameID, client_id) 170 | # ... and we send it to Pulsar for everyone: 171 | pulsarProducer.send((json.dumps(leavingUpdate)).encode('utf-8')) 172 | -------------------------------------------------------------------------------- /api/database/dal.py: -------------------------------------------------------------------------------- 1 | """ 2 | dal.py 3 | Cassandra (or Astra DB) access layer 4 | """ 5 | 6 | from .dbsession import session 7 | 8 | 9 | # prepared statements are created here for later usage: 10 | psInsertObjectCQL = session.prepare( 11 | 'INSERT INTO objects_by_game_id (game_id, kind, object_id, active, ' 12 | 'x, y, h, generation, name) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? );' 13 | ) 14 | psInsertObjectCoordinatesCQL = session.prepare( 15 | 'INSERT INTO objects_by_game_id (game_id, kind, object_id, ' 16 | 'active, x, y) VALUES ( ?, ?, ?, ?, ?, ? );' 17 | ) 18 | psSelectObjectByIDCQL = session.prepare( 19 | 'SELECT object_id, x, y, h, generation, name FROM objects_by_game_id WHERE ' 20 | 'game_id = ? AND kind = ? AND object_id = ?' 21 | ) 22 | psSelectObjectsCQL = session.prepare( 23 | 'SELECT kind, object_id, active, x, y, h, generation, name FROM ' 24 | 'objects_by_game_id WHERE game_id = ?;' 25 | ) 26 | psInsertObjectActivenessCQL = session.prepare( 27 | 'INSERT INTO objects_by_game_id (game_id, kind, object_id, active) VALUES ' 28 | '(?, ?, ?, ?);' 29 | ) 30 | psSelectObjectsShortCQL = session.prepare( 31 | 'SELECT kind, object_id, active, x, y, name FROM ' 32 | 'objects_by_game_id WHERE game_id = ?;' 33 | ) 34 | psSelectObjectsShortByKindCQL = session.prepare( 35 | 'SELECT object_id, active, x, y FROM ' 36 | 'objects_by_game_id WHERE game_id = ? AND kind = ?;' 37 | ) 38 | 39 | ## 40 | 41 | 42 | def storeActivity(uGameID, kind, uObjectID, active): 43 | session.execute( 44 | psInsertObjectActivenessCQL, 45 | ( 46 | uGameID, kind, uObjectID, active, 47 | ), 48 | ) 49 | 50 | 51 | def storeObject(uGameID, kind, uObjectID, active, x, y, h, generation, name): 52 | session.execute( 53 | psInsertObjectCQL, 54 | ( 55 | uGameID, kind, uObjectID, active, x, y, h, generation, name, 56 | ), 57 | ) 58 | 59 | 60 | def storeCoordinates(uGameID, kind, uObjectID, active, x, y): 61 | session.execute( 62 | psInsertObjectCoordinatesCQL, 63 | ( 64 | uGameID, kind, uObjectID, active, x, y, 65 | ), 66 | ) 67 | 68 | 69 | def retrieveByGameID(uGameID): 70 | results = session.execute( 71 | psSelectObjectsCQL, 72 | (uGameID, ), 73 | ) 74 | return ( 75 | row._asdict() 76 | for row in results 77 | ) 78 | 79 | 80 | 81 | def retrieveShortByGameID(uGameID): 82 | results = session.execute( 83 | psSelectObjectsShortCQL, 84 | (uGameID, ), 85 | ) 86 | return ( 87 | row._asdict() 88 | for row in results 89 | ) 90 | 91 | 92 | def retrieveObjectByID(uGameID, kind, uObjectID): 93 | result = session.execute( 94 | psSelectObjectByIDCQL, 95 | ( 96 | uGameID, kind, uObjectID, 97 | ), 98 | ).one() 99 | if result is None: 100 | return None 101 | else: 102 | return result._asdict() 103 | 104 | 105 | def retrieveOneShortByKind(uGameID, kind): 106 | item = session.execute( 107 | psSelectObjectsShortByKindCQL, 108 | ( 109 | uGameID, kind, 110 | ), 111 | ).one() 112 | if item is not None: 113 | return item._asdict() 114 | else: 115 | return None 116 | -------------------------------------------------------------------------------- /api/database/dbsession.py: -------------------------------------------------------------------------------- 1 | """ 2 | session.py 3 | """ 4 | 5 | import os 6 | import atexit 7 | from cassandra.cluster import Cluster 8 | from cassandra.auth import PlainTextAuthProvider 9 | from dotenv import load_dotenv 10 | 11 | # Credentials retrieval 12 | load_dotenv() 13 | try: 14 | SECURE_CONNECT_BUNDLE = os.environ['ASTRA_DB_SECURE_CONNECT_BUNDLE'] 15 | USERNAME = os.environ['ASTRA_DB_USERNAME'] 16 | PASSWORD = os.environ['ASTRA_DB_PASSWORD'] 17 | KEYSPACE = os.environ['ASTRA_DB_KEYSPACE'] 18 | # 19 | secure_connect_bundle = SECURE_CONNECT_BUNDLE 20 | path_to_creds = '' 21 | cluster = Cluster( 22 | cloud={ 23 | 'secure_connect_bundle': SECURE_CONNECT_BUNDLE 24 | }, 25 | auth_provider=PlainTextAuthProvider(USERNAME, PASSWORD) 26 | ) 27 | session = cluster.connect(KEYSPACE) 28 | 29 | # initialization of tables 30 | tableCreationResult = session.execute(''' 31 | CREATE TABLE IF NOT EXISTS objects_by_game_id ( 32 | game_id UUID, 33 | kind TEXT, 34 | object_id UUID, 35 | "active" BOOLEAN, 36 | x INT, 37 | y INT, 38 | h BOOLEAN, 39 | generation INT, 40 | name TEXT, 41 | PRIMARY KEY ( (game_id), kind, object_id) 42 | ) WITH CLUSTERING ORDER BY (kind DESC, object_id DESC); 43 | ''') 44 | 45 | @atexit.register 46 | def shutdown_driver(): 47 | cluster.shutdown() 48 | session.shutdown() 49 | 50 | except KeyError: 51 | raise KeyError('Environment variables not detected. Perhaps you forgot to ' 52 | 'prepare the .env file ?') 53 | -------------------------------------------------------------------------------- /api/gameStatus.py: -------------------------------------------------------------------------------- 1 | """ 2 | gameStatus.py 3 | A persistence layer for the game. 4 | For simplicity, player-info entering and exiting this library 5 | are directly in the form of "player" messages. A bit of a tight 6 | coupling, but for illustration purposes it keeps everything simpler 7 | (one may want to more clearly decouple the two representations, 8 | row and 'player' message, in a more structured application). 9 | """ 10 | 11 | import os 12 | import uuid 13 | from dotenv import load_dotenv 14 | 15 | from messaging import (makePositionUpdate, pickBrickPositions, makeBrickUpdate, 16 | pickFoodPositions, makeFoodUpdate) 17 | 18 | ### 19 | 20 | 21 | load_dotenv() 22 | USE_IN_MEMORY_STORAGE = int(os.environ.get('USE_IN_MEMORY_STORAGE', '0')) != 0 23 | 24 | 25 | if USE_IN_MEMORY_STORAGE: 26 | from inmemory.dal import (storeActivity, storeObject, storeCoordinates, 27 | retrieveByGameID, retrieveShortByGameID, 28 | retrieveObjectByID, retrieveOneShortByKind) 29 | print('\n\n*** USING IN-MEMORY STORAGE ***\n\n') 30 | else: 31 | from database.dal import (storeActivity, storeObject, storeCoordinates, 32 | retrieveByGameID, retrieveShortByGameID, 33 | retrieveObjectByID, retrieveOneShortByKind) 34 | 35 | 36 | def _dbRowToPlayerMessage(row): 37 | # args are: client_id, client_name, x, y, h, generation 38 | # leaky defaults are treated here (name, generation) 39 | return makePositionUpdate( 40 | str(row['object_id']), 41 | row.get('name', 'unnamed'), 42 | row['x'], 43 | row['y'], 44 | row.get('h', False), 45 | row.get('generation', 0), 46 | ) 47 | 48 | 49 | def _dbRowToBrickMessage(row): 50 | return makeBrickUpdate( 51 | row['name'], 52 | row['x'], 53 | row['y'], 54 | ) 55 | 56 | 57 | def _dbRowToFoodMessage(row): 58 | return makeFoodUpdate( 59 | row['object_id'], 60 | row['name'], 61 | row['x'], 62 | row['y'], 63 | ) 64 | 65 | 66 | def _dbRowToMessage(row): 67 | if row['kind'] == 'player': 68 | return _dbRowToPlayerMessage(row) 69 | elif row['kind'] == 'brick': 70 | return _dbRowToBrickMessage(row) 71 | elif row['kind'] == 'food': 72 | return _dbRowToFoodMessage(row) 73 | else: 74 | raise NotImplementedError('Unknown row kind "%s"' % row['kind']) 75 | 76 | ### 77 | 78 | 79 | def storeGameActivePlayer(gameID, playerID): 80 | """ 81 | Side-effect only: marking a player as 'active (again) on board' 82 | (i.e. when coming back to same game). 83 | """ 84 | storeActivity( 85 | uuid.UUID(gameID), 86 | 'player', 87 | uuid.UUID(playerID), 88 | True, 89 | ) 90 | 91 | def storeGameInactivePlayer(gameID, playerID): 92 | """ 93 | Side-effect only: marking a player as 'inactive from board' 94 | (i.e. when disconnecting from game). 95 | """ 96 | storeActivity( 97 | uuid.UUID(gameID), 98 | 'player', 99 | uuid.UUID(playerID), 100 | False, 101 | ) 102 | 103 | 104 | def storeFoodItemStatus(gameID, foodUpdate): 105 | """ 106 | Side-effect only: stores the last location/status of 107 | a food item on the field. 108 | 109 | Input is a 'food' message, parsed here internally 110 | """ 111 | # 112 | pLoad = foodUpdate['payload'] 113 | foodID = foodUpdate['foodID'] 114 | storeObject( 115 | uuid.UUID(gameID), 116 | 'food', 117 | uuid.UUID(foodID), 118 | True, 119 | pLoad['x'], 120 | pLoad['y'], 121 | False, 122 | 0, 123 | pLoad['name'], 124 | ) 125 | 126 | 127 | def storeGamePlayerStatus(gameID, playerUpdate): 128 | """ 129 | Side-effect only: stores the last location/status of 130 | a player on the field. 131 | 132 | Input is a 'player' message, parsed here internally 133 | """ 134 | # 135 | pLoad = playerUpdate['payload'] 136 | playerID = playerUpdate['playerID'] 137 | storeObject( 138 | uuid.UUID(gameID), 139 | 'player', 140 | uuid.UUID(playerID), 141 | True, 142 | pLoad['x'], 143 | pLoad['y'], 144 | pLoad['h'], 145 | pLoad['generation'], 146 | pLoad['name'], 147 | ) 148 | 149 | 150 | def storeGamePlayerPosition(gameID, playerID, active, x, y): 151 | """ 152 | Store new coordinates (server-forced), for later validation etc. 153 | """ 154 | storeCoordinates( 155 | uuid.UUID(gameID), 156 | 'player', 157 | uuid.UUID(playerID), 158 | active, 159 | x, 160 | y, 161 | ) 162 | 163 | 164 | def retrieveActiveGameItems(gameID, excludedPlayerIDs = set()): 165 | """ 166 | Return active players only. Output are 'player' messages ready-to-send. 167 | """ 168 | results = retrieveByGameID(uuid.UUID(gameID)) 169 | # 170 | return ( 171 | _dbRowToMessage(row) 172 | for row in results 173 | if row['active'] 174 | if row['kind'] != 'player' or str(row['object_id']) not in excludedPlayerIDs 175 | ) 176 | 177 | 178 | def retrieveFieldOccupancy(gameID): 179 | """ 180 | Return a map (x, y) -> {kind: ... , object_id: ...}, skips inactives 181 | """ 182 | results = retrieveShortByGameID(uuid.UUID(gameID)) 183 | # 184 | return { 185 | (row['x'], row['y']): { 186 | 'kind': row['kind'], 187 | 'object_id': row['object_id'], 188 | 'name': row.get('name'), 189 | } 190 | for row in results 191 | if row['active'] 192 | } 193 | 194 | 195 | def retrieveGamePlayerStatus(gameID, playerID): 196 | """ 197 | Return None if no info found, 198 | else a 'player' message (which, as such, knows of no 'active' flag). 199 | """ 200 | result = retrieveObjectByID( 201 | uuid.UUID(gameID), 202 | 'player', 203 | uuid.UUID(playerID), 204 | ) 205 | # 206 | if result is None: 207 | return None 208 | else: 209 | return _dbRowToPlayerMessage(result) 210 | 211 | 212 | def layBricks(gameID, HALF_SIZE_X, HALF_SIZE_Y, BRICK_FRACTION): 213 | """ 214 | this creates the bricks for the game. 215 | It should run only once per gameID (hence we perform a read and make 216 | sure there are no bricks), but before any player joins. 217 | """ 218 | 219 | # first check if there are bricks already (even just one) 220 | prevBrick = retrieveOneShortByKind( 221 | uuid.UUID(gameID), 222 | 'brick', 223 | ) 224 | # 225 | if prevBrick is not None: 226 | # bricks already present 227 | return 228 | else: 229 | # we create the required bricks 230 | brickPositions = pickBrickPositions( 231 | 2*HALF_SIZE_X-1, 232 | 2*HALF_SIZE_Y-1, 233 | BRICK_FRACTION, 234 | ) 235 | # we store the bricks for this game 236 | _gameID = uuid.UUID(gameID) 237 | for bi, (bx, by) in enumerate(brickPositions): 238 | storeObject( 239 | _gameID, 240 | 'brick', 241 | uuid.uuid4(), 242 | True, 243 | bx, 244 | by, 245 | False, 246 | 0, 247 | 'brick_%04i' % bi, 248 | ) 249 | 250 | 251 | def layFood(gameID, HALF_SIZE_X, HALF_SIZE_Y, NUM_FOOD_ITEMS): 252 | """ 253 | we read the gamefield and place an exact number of food 254 | items on the board, unless there are already some. 255 | """ 256 | # first check if there are food items already (even just one) 257 | prevFood = retrieveOneShortByKind( 258 | uuid.UUID(gameID), 259 | 'food', 260 | ) 261 | # 262 | if prevFood is not None: 263 | # food already present 264 | return 265 | else: 266 | # we survey forbidden locations first 267 | occupancyMap = retrieveFieldOccupancy(gameID) 268 | # we create the required food 269 | foodPositions = pickFoodPositions( 270 | 2*HALF_SIZE_X-1, 271 | 2*HALF_SIZE_Y-1, 272 | NUM_FOOD_ITEMS, 273 | occupancyMap, 274 | ) 275 | # we store the bricks for this game 276 | _gameID = uuid.UUID(gameID) 277 | for fi, (fx, fy) in enumerate(foodPositions): 278 | storeObject( 279 | _gameID, 280 | 'food', 281 | uuid.uuid4(), 282 | True, 283 | fx, 284 | fy, 285 | False, 286 | 0, 287 | 'food_%04i' % fi, 288 | ) 289 | -------------------------------------------------------------------------------- /api/inmemory/dal.py: -------------------------------------------------------------------------------- 1 | """ 2 | dal.py 3 | In-memory access layer, alternative to an actual storage. 4 | 5 | Useful for quick debugging or to overcome less-than-ideal deployments, 6 | in particular those with the API far from the DB (e.g. Gitpod vs Astra DB). 7 | This helps relieve latency issues, at the price that the API would not 8 | be able to scale to more processes as the whole "persisted game state" 9 | remains process-local. 10 | 11 | Note: not particularly optimized (or elegant at that). 12 | """ 13 | 14 | 15 | # a map gameID -> kind -> objectID -> whole dict 16 | memStorage = {} 17 | 18 | 19 | def _ensureGameID(uGameID): 20 | memStorage[uGameID] = memStorage.get(uGameID, {}) 21 | 22 | 23 | def _ensureKind(uGameID, kind): 24 | _ensureGameID(uGameID) 25 | memStorage[uGameID][kind] = memStorage[uGameID].get(kind, {}) 26 | 27 | 28 | def _ensureObjectID(uGameID, kind, uObjectID): 29 | _ensureKind(uGameID, kind) 30 | memStorage[uGameID][kind][uObjectID] = memStorage[uGameID][kind].get(uObjectID, {}) 31 | 32 | 33 | def _qualify(gid, kdd, oid, rec, columns=None): 34 | # add the "primary key" to entries found in the mem storage 35 | r = {k: v for k, v in rec.items()} 36 | r['game_id'] = gid 37 | r['kind'] = kdd 38 | r['object_id'] = oid 39 | return { 40 | k: v 41 | for k, v in r.items() 42 | if columns is None or k in columns 43 | } 44 | 45 | 46 | ## 47 | 48 | 49 | def storeActivity(uGameID, kind, uObjectID, active): 50 | _ensureObjectID(uGameID, kind, uObjectID) 51 | # 52 | memStorage[uGameID][kind][uObjectID]['active'] = active 53 | 54 | 55 | def storeObject(uGameID, kind, uObjectID, active, x, y, h, generation, name): 56 | _ensureObjectID(uGameID, kind, uObjectID) 57 | # 58 | memStorage[uGameID][kind][uObjectID] = { 59 | 'active': active, 60 | 'x': x, 61 | 'y': y, 62 | 'h': h, 63 | 'generation': generation, 64 | 'name': name, 65 | } 66 | 67 | 68 | def storeCoordinates(uGameID, kind, uObjectID, active, x, y): 69 | _ensureObjectID(uGameID, kind, uObjectID) 70 | # 71 | memStorage[uGameID][kind][uObjectID]['x'] = x 72 | memStorage[uGameID][kind][uObjectID]['y'] = y 73 | memStorage[uGameID][kind][uObjectID]['active'] = active 74 | 75 | 76 | def retrieveByGameID(uGameID): 77 | _ensureGameID(uGameID) 78 | # 79 | return ( 80 | _qualify( 81 | uGameID, 82 | kd, 83 | oID, 84 | record, 85 | {'kind', 'object_id', 'active', 'x', 'y', 'h', 'generation', 'name'}, 86 | ) 87 | for kd, oidmap in memStorage[uGameID].items() 88 | for oID, record in oidmap.items() 89 | ) 90 | 91 | 92 | def retrieveShortByGameID(uGameID): 93 | _ensureGameID(uGameID) 94 | # 95 | return ( 96 | _qualify( 97 | uGameID, 98 | kd, 99 | oID, 100 | record, 101 | {'kind', 'object_id', 'active', 'x', 'y', 'name'}, 102 | ) 103 | for kd, oidmap in memStorage[uGameID].items() 104 | for oID, record in oidmap.items() 105 | ) 106 | 107 | 108 | def retrieveObjectByID(uGameID, kind, uObjectID): 109 | _ensureKind(uGameID, kind) 110 | # 111 | if uObjectID in memStorage[uGameID][kind]: 112 | return _qualify( 113 | uGameID, 114 | kind, 115 | uObjectID, 116 | memStorage[uGameID][kind][uObjectID], 117 | {'object_id', 'x', 'y', 'h', 'generation', 'name'}, 118 | ) 119 | else: 120 | return None 121 | 122 | 123 | def retrieveOneShortByKind(uGameID, kind): 124 | _ensureKind(uGameID, kind) 125 | # 126 | found = [ 127 | _qualify( 128 | uGameID, 129 | kind, 130 | oID, 131 | record, 132 | {'object_id', 'active', 'x', 'y'}, 133 | ) 134 | for oID, record in memStorage[uGameID][kind].items() 135 | ] 136 | if found == []: 137 | return None 138 | else: 139 | return found[0] 140 | -------------------------------------------------------------------------------- /api/messaging.py: -------------------------------------------------------------------------------- 1 | """ 2 | messaging.py 3 | """ 4 | 5 | import time 6 | import random 7 | from uuid import uuid4 8 | 9 | from utils import dictMerge 10 | 11 | 12 | # poor randomness, not a problem here 13 | random.seed(int(time.time())) 14 | 15 | 16 | def pickBrickPositions(w, h, fraction): 17 | """ 18 | sparseness assumption: we don't care much 19 | about double-counting, eh. 20 | """ 21 | return { 22 | ( 23 | random.randint(0, w-1), 24 | random.randint(0, h-1), 25 | ) 26 | for _ in range(int(w*h*fraction)) 27 | } 28 | 29 | 30 | def pickFoodPositions(w, h, num, occupancyMap): 31 | """ 32 | we want this to return exactly 'num' items 33 | """ 34 | freeCells = [ 35 | (x, y) 36 | for x in range(w) 37 | for y in range(h) 38 | if (x, y) not in occupancyMap 39 | ] 40 | # ... unless there are less than that to choose from (!) 41 | _num = min(len(freeCells), num) 42 | # We would like to have a cool permutation but importing numpy for 43 | # this feels like an overkill so here you go with a lazy mutable-based 44 | # solution (meh) 45 | chosenPositions = set() 46 | while len(chosenPositions) < _num: 47 | proposal = freeCells[random.randint(0, len(freeCells)-1)] 48 | chosenPositions = chosenPositions | {proposal} 49 | # 50 | return chosenPositions 51 | 52 | 53 | def makeCoordPair(updDict): 54 | if updDict is None: 55 | return (None, None) 56 | else: 57 | return ( 58 | updDict['payload']['x'], 59 | updDict['payload']['y'], 60 | ) 61 | 62 | 63 | def validatePosition(updDict, halfX, halfY, fieldOccupancy, prevUpdate): 64 | """ 65 | Utility function to keep the 'x' and 'y' in a dict 66 | bound within the game field, 67 | respecting other keys in the passed dict. 68 | 69 | RETURN a 2-tuple (validatedUpdateDict, None-or-foodItem) 70 | """ 71 | def _constrain(val, minv, maxv): 72 | return max(minv, min(val, maxv)) 73 | 74 | # update wants to get to these coordinates: 75 | updX = updDict['payload']['x'] 76 | updY = updDict['payload']['y'] 77 | # None values are a init transient, we ignore validation 78 | 79 | if updX is None or updY is None: 80 | # "everything goes" 81 | return updDict, None 82 | else: 83 | cnewX = _constrain(updX, 0, 2*halfX - 2) 84 | cnewY = _constrain(updY, 0, 2*halfY - 2) 85 | 86 | # can player get to that position? and, does it catch food? 87 | if (cnewX, cnewY) not in fieldOccupancy: 88 | targetIsFree = True 89 | caughtFoodItem = None 90 | elif fieldOccupancy[(cnewX, cnewY)]['kind'] == 'food': 91 | targetIsFree = True 92 | caughtFoodItem = fieldOccupancy[(cnewX, cnewY)] 93 | else: 94 | targetIsFree = False 95 | caughtFoodItem = None 96 | 97 | # is the move too long a jump? 98 | if prevUpdate is None: 99 | curX = halfX - 1 100 | curY = halfY - 1 101 | else: 102 | curX = prevUpdate['payload']['x'] 103 | curY = prevUpdate['payload']['y'] 104 | if curX is not None and curY is not None: 105 | targetIsNear = abs(curX - cnewX) < 2 and abs(curY - cnewY) < 2 106 | else: 107 | targetIsNear = True 108 | # 109 | canGetThere = targetIsFree and targetIsNear 110 | if canGetThere: 111 | # yes, it can 112 | newPosPayload = { 113 | 'x': cnewX, 114 | 'y': cnewY, 115 | } 116 | return dictMerge( 117 | { 118 | 'payload': newPosPayload, 119 | }, 120 | default=updDict, 121 | ), caughtFoodItem 122 | else: 123 | cbX = curX 124 | cbY = curY 125 | newPosPayload = { 126 | 'x': cbX, 127 | 'y': cbY, 128 | } 129 | return dictMerge( 130 | { 131 | 'payload': newPosPayload, 132 | }, 133 | default=updDict, 134 | ), caughtFoodItem 135 | 136 | 137 | def makePositionUpdate(client_id, client_name, x, y, h, generation): 138 | return { 139 | 'messageType': 'player', 140 | 'playerID': client_id, 141 | 'payload': { 142 | 'x': x, 143 | 'y': y, 144 | 'h': h, 145 | 'generation': generation, 146 | 'name': client_name, 147 | }, 148 | } 149 | 150 | 151 | def makeEnteringPositionUpdate(client_id, client_name, halfX, halfY, 152 | occupancyMap): 153 | # we randomize and take care to avoid cells with anything in them 154 | freeCells = [ 155 | (x, y) 156 | for x in range(2*halfX - 1) 157 | for y in range(2*halfY - 1) 158 | if (x, y) not in occupancyMap 159 | ] 160 | if len(freeCells) > 0: 161 | tX, tY = freeCells[random.randint(0, len(freeCells)-1)] 162 | else: 163 | tX = halfX - 1 164 | tY = halfY - 1 165 | # 166 | return makePositionUpdate(client_id, client_name, tX, tY, 167 | False, 0) 168 | 169 | 170 | def makeLeavingUpdate(client_id): 171 | """ 172 | A default 'leaving' message to publish to the Pulsar topic 173 | in case a client disconnection is detected 174 | """ 175 | return { 176 | 'messageType': 'leaving', 177 | 'playerID': client_id, 178 | 'payload': { 179 | 'name': '', 180 | }, 181 | } 182 | 183 | 184 | def makeServerChatUpdate(client_id, text): 185 | return { 186 | 'messageType': 'chat', 187 | 'payload': { 188 | 'id': str(uuid4()), 189 | 'name': '** API **', 190 | 'text': text, 191 | }, 192 | 'playerID': '_api_server_', 193 | } 194 | 195 | 196 | def makeWelcomeUpdate(client_id, name='Unnamed'): 197 | """ 198 | A server-generated chat message to greet a new player 199 | """ 200 | return makeServerChatUpdate(client_id, 'Welcome to the game, %s!' % name) 201 | 202 | 203 | def makeGeometryUpdate(hsX, hsY): 204 | """ 205 | Prepare a message containing the field geometry info 206 | """ 207 | return { 208 | 'messageType': 'geometry', 209 | 'payload': { 210 | 'halfSizeX': hsX, 211 | 'halfSizeY': hsY, 212 | }, 213 | } 214 | 215 | 216 | def makeBrickUpdate(brick_name, x, y): 217 | return { 218 | 'messageType': 'brick', 219 | 'payload': { 220 | 'x': x, 221 | 'y': y, 222 | 'name': brick_name, 223 | }, 224 | } 225 | 226 | 227 | def makeFoodUpdate(food_id, food_name, x, y): 228 | return { 229 | 'messageType': 'food', 230 | 'foodID': str(food_id), 231 | 'payload': { 232 | 'x': x, 233 | 'y': y, 234 | 'name': food_name, 235 | }, 236 | } 237 | -------------------------------------------------------------------------------- /api/pulsarTools.py: -------------------------------------------------------------------------------- 1 | """ 2 | pulsarTools.py 3 | """ 4 | 5 | import os 6 | import pulsar 7 | from dotenv import load_dotenv 8 | 9 | 10 | load_dotenv() 11 | try: 12 | STREAMING_TENANT = os.environ['STREAMING_TENANT'] 13 | STREAMING_NAMESPACE = os.environ['STREAMING_NAMESPACE'] 14 | STREAMING_TOPIC = os.environ['STREAMING_TOPIC'] 15 | STREAMING_SERVICE_URL = os.environ['STREAMING_SERVICE_URL'] 16 | TRUST_CERTS = os.environ['TRUST_CERTS'] 17 | ASTRA_STREAMING_TOKEN = os.environ['ASTRA_STREAMING_TOKEN'] 18 | # 19 | client = pulsar.Client( 20 | STREAMING_SERVICE_URL, 21 | authentication=pulsar.AuthenticationToken(ASTRA_STREAMING_TOKEN), 22 | tls_trust_certs_file_path=TRUST_CERTS, 23 | ) 24 | except KeyError: 25 | raise KeyError('Environment variables not detected. Perhaps you forgot to ' 26 | 'prepare the .env file ?') 27 | 28 | streamingTopic = 'persistent://{tenant}/{namespace}/{topic}'.format( 29 | tenant=STREAMING_TENANT, 30 | namespace=STREAMING_NAMESPACE, 31 | topic=STREAMING_TOPIC, 32 | ) 33 | 34 | 35 | # Pulsar producer/consumer caches 36 | # (for parsimonious allocation of resources) 37 | consumerCache = {} 38 | cachedProducer = None 39 | 40 | 41 | def getPulsarClient(): 42 | return client 43 | 44 | 45 | def getConsumer(clientID, puClient): 46 | global consumerCache 47 | # 48 | if clientID not in consumerCache: 49 | pulsarSubscription = f'sub_{clientID}' 50 | consumerCache[clientID] = puClient.subscribe(streamingTopic, 51 | pulsarSubscription) 52 | # 53 | return consumerCache[clientID] 54 | 55 | 56 | def getProducer(puClient): 57 | global cachedProducer 58 | # 59 | if cachedProducer is None: 60 | cachedProducer = puClient.create_producer(streamingTopic) 61 | # 62 | return cachedProducer 63 | 64 | 65 | def receiveOrNone(consumer, timeout): 66 | """ 67 | A modified 'receive' function for a Pulsar topic 68 | that handles timeouts so that when the topic is empty 69 | it simply returns None. 70 | """ 71 | try: 72 | msg = consumer.receive(timeout) 73 | return msg 74 | except Exception as e: 75 | if 'timeout' in str(e).lower(): 76 | return None 77 | else: 78 | raise e 79 | -------------------------------------------------------------------------------- /api/requirements.txt: -------------------------------------------------------------------------------- 1 | cassandra-driver==3.25.0 2 | fastapi==0.68.1 3 | pulsar-client==2.7.3 4 | python-dotenv==0.19.1 5 | uvicorn==0.15.0 6 | websockets==10.0 -------------------------------------------------------------------------------- /api/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | settings.py 3 | """ 4 | 5 | # Game field (half) size 6 | HALF_SIZE_X = 20 7 | HALF_SIZE_Y = 12 8 | # field layout settings 9 | BRICK_FRACTION = 0.025 10 | NUM_FOOD_ITEMS = 3 11 | 12 | # Timeouts and waiting times 13 | RECEIVE_TIMEOUTS_MS = 5 14 | SLEEP_BETWEEN_READS_MS = 1 15 | -------------------------------------------------------------------------------- /api/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils.py 3 | """ 4 | 5 | def dictMerge(main, default): 6 | """ 7 | Pure dict deep-merge function. First dict has precedence. 8 | """ 9 | if isinstance(main, dict): 10 | return { 11 | k: dictMerge( 12 | main[k], 13 | default=({} if default is None else default).get(k), 14 | ) if k in main else default[k] 15 | for k in ({} if default is None else default.keys()) | main.keys() 16 | } 17 | else: 18 | return main 19 | -------------------------------------------------------------------------------- /astra.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Drapetisca: a multiplayer online game with Astra Streaming and Websockets", 3 | "description": "A simple multiplayer online game (with in-game chat) featuring: Astra Streaming, Astra DB, WebSockets, React for the front-end and FastAPI for the back-end. Also spiders!", 4 | "duration": "120 minutes", 5 | "skillLevel": "Intermediate", 6 | "language":["javascript", "python"], 7 | "stack":["websockets", "pulsar", "cassandra", "react", "fastAPI"], 8 | "githubUrl": "https://github.com/datastaxdevs/workshop-streaming-game", 9 | "badge": "https://media.badgr.com/uploads/badges/98db33b1-ae34-4445-a4eb-546a4f4b8567.png", 10 | "youTubeUrl": [ "https://www.youtube.com/watch?v=jfOBPlcd9eA" ], 11 | "tags": [ 12 | { "name": "workshop" }, 13 | { "name":"event streaming" }, 14 | { "name":"real-time" }, 15 | { "name":"astradb" }, 16 | { "name":"astrastreaming" }, 17 | { "name":"gaming" }, 18 | { "name":"cassandra" } 19 | ], 20 | "category": "workshop", 21 | "usecases": ["Online gaming", "Push architecture", "Chat applications"] 22 | } 23 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drapetisca-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.14.1", 7 | "@testing-library/react": "^11.2.7", 8 | "@testing-library/user-event": "^12.8.3", 9 | "react": "^17.0.2", 10 | "react-dom": "^17.0.2", 11 | "react-scripts": "4.0.3", 12 | "uuid": "^8.3.2", 13 | "web-vitals": "^1.1.2" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/public/astra-streaming-stacked-pos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/client/public/astra-streaming-stacked-pos.png -------------------------------------------------------------------------------- /client/public/brick.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 44 | 45 | -------------------------------------------------------------------------------- /client/public/food.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 50 | 51 | -------------------------------------------------------------------------------- /client/public/hearts.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Drapetisca 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/public/lyco_self.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 44 | 48 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | body { 17 | background-color: gold; 18 | } 19 | 20 | .App-header { 21 | background-color: #98bcd4; 22 | display: flex; 23 | padding-bottom: 10px; 24 | border: 1px solid #777; 25 | border-radius: 4px; 26 | flex-direction: column; 27 | align-items: center; 28 | justify-content: center; 29 | font-size: calc(10px + 2vmin); 30 | color: white; 31 | margin: 5px; 32 | } 33 | 34 | .game-inputs { 35 | } 36 | 37 | .grid-container { 38 | display: grid; 39 | grid-template-columns: auto auto auto; 40 | grid-gap: 2px; 41 | padding: 0 60px; 42 | } 43 | 44 | .grid-container > div { 45 | text-align: center; 46 | font-size: 30px; 47 | } 48 | 49 | .container { 50 | display: flex; 51 | flex-direction: row; 52 | } 53 | 54 | .game-field { 55 | border-radius: 15px; 56 | border-width: 2px; 57 | border: 8px solid #20b020; 58 | } 59 | 60 | .sidebar, 61 | .content { 62 | display: flex; 63 | flex-direction: column; 64 | background: #98bcd4; 65 | align-items: center; 66 | min-height: 500px; 67 | border-radius: 4px; 68 | padding-top: 10px; 69 | margin: 5px; 70 | border: 1px solid #777; 71 | } 72 | 73 | .sidebar { 74 | flex-grow: 1; 75 | min-width: 300px; 76 | max-width: 350px; 77 | } 78 | 79 | .content { 80 | flex-grow: 10; 81 | min-width: 630px; 82 | padding: 15px; 83 | text-align: center; 84 | } 85 | 86 | .astra-logo { 87 | padding-top: 40px; 88 | } 89 | 90 | .astra-logo > img { 91 | width: 90px; 92 | } 93 | 94 | .player-position { 95 | border-radius: 8px; 96 | border: 1px solid #777; 97 | padding: 10px; 98 | margin: 0 0 20px 0; 99 | } 100 | 101 | .game-title { 102 | border-radius: 8px; 103 | border: 1px solid #777; 104 | padding: 4px; 105 | margin: 5px 0 5px 0; 106 | font-size: 20px; 107 | color: black; 108 | } 109 | 110 | .game-title-name { 111 | color: blue; 112 | } 113 | 114 | .game-title-player-name { 115 | color: purple; 116 | font-weight: bold; 117 | margin: 0 20px 0 20px; 118 | } 119 | 120 | .reference-position { 121 | font-size: 10px; 122 | color: #555555; 123 | } 124 | 125 | .chat-area { 126 | margin-top: 60px; 127 | border-radius: 8px; 128 | padding: 20px; 129 | background: #f9ffbb; 130 | } 131 | 132 | .statusbar { 133 | background: #98bcd4; 134 | font-size: 70%; 135 | text-align: left; 136 | margin: 5px; 137 | border: 1px solid #777; 138 | padding: 5px; 139 | border-radius: 4px; 140 | } 141 | 142 | .game-message { 143 | color: blue; 144 | font-weight: bold; 145 | font-size: 110%; 146 | } 147 | 148 | .chat-title { 149 | font-size: 150%; 150 | color: blue; 151 | font-variant: small-caps; 152 | } 153 | 154 | .chat-list { 155 | text-align: left; 156 | list-style-type: none; 157 | padding: 0; 158 | margin: 0; 159 | padding-bottom: 15px; 160 | padding-top: 10px; 161 | min-height: 100px; 162 | } 163 | 164 | .chat-item { 165 | font-size: 75%; 166 | } 167 | 168 | .chat-player-name { 169 | font-style: italic; 170 | } 171 | 172 | .player-self { 173 | color: #10b010; 174 | fill: #10b010; 175 | } 176 | 177 | .player-other { 178 | color: #bf99ff; 179 | fill: #bf99ff; 180 | } 181 | 182 | .player-name { 183 | fill: #404040; 184 | } 185 | 186 | .arrow-key { 187 | background-color: #10b010; 188 | border: 1px solid black; 189 | color: black; 190 | padding: 10px; 191 | margin: 5px; 192 | text-align: center; 193 | font-size: 25px; 194 | } -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | 3 | import { useEffect, useState, useRef } from "react" 4 | 5 | import PlayerForm from "./components/PlayerForm" 6 | import GameArea from "./components/GameArea" 7 | 8 | import {packPlayerMessage, packChatMessage, packEnteringMessage, packLeavingMessage} from "./utils/messages" 9 | import {updatePositionMapValue, removePlayerMapEntry} from "./utils/playerMaps" 10 | import guessAPILocation from "./utils/guessAPILocation" 11 | import getRandomSpiderName from "./utils/names" 12 | 13 | const settings = require('./settings') 14 | const uuid = require('uuid'); 15 | const playerID = uuid.v4(); 16 | 17 | // web-sockets 18 | let wws = null; 19 | let pws = null; 20 | 21 | const App = () => { 22 | 23 | const proposedAPILocation = guessAPILocation(window.location.href, settings.DEFAULT_API_LOCATION); 24 | 25 | const [apiLocation, setApiLocation] = useState(proposedAPILocation); 26 | const [inGame, setInGame] = useState(false); 27 | const [playerMap, setPlayerMap] = useState({}); 28 | const [brickList, setBrickList] = useState([]); 29 | const [foodMap, setFoodMap] = useState({}); 30 | // 31 | // 'playerInitialized' starts False when entering the game and then jumps to 32 | // True as soon as the API acknowledges the player and gives it info/coordinates. 33 | const [playerInitialized, setPlayerInitialized] = useState(false); 34 | const [playerX, setPlayerX] = useState(null); 35 | const [playerY, setPlayerY] = useState(null); 36 | const [playerH, setPlayerH] = useState(false) 37 | const [generation, setGeneration] = useState(0) 38 | const [playerName, setPlayerName] = useState(getRandomSpiderName()); 39 | // 40 | const [halfSizeX, setHalfSizeX] = useState(null); 41 | const [halfSizeY, setHalfSizeY] = useState(null); 42 | const [lastSent, setLastSent] = useState(null) 43 | const [lastReceived, setLastReceived] = useState(null) 44 | const [chatItems, setChatItems] = useState([]) 45 | 46 | // With useRef we can make the updated state accessible from within the callback 47 | // that we will attach to the websocket. 48 | // see https://stackoverflow.com/questions/57847594/react-hooks-accessing-up-to-date-state-from-within-a-callback 49 | const generationRef = useRef() 50 | generationRef.current = generation 51 | // Same for the player position/status, since it will be accessed within callbacks: 52 | const playerXRef = useRef() 53 | playerXRef.current = playerX 54 | const playerYRef = useRef() 55 | playerYRef.current = playerY 56 | const playerHRef = useRef() 57 | playerHRef.current = playerH 58 | 59 | // keyboard-controlled movements 60 | const handleKeyDown = ev => { 61 | if(inGame){ 62 | if ( ev.keyCode === 37 ){ 63 | setPlayerX( x => x-1 ) 64 | setPlayerH(false) 65 | } 66 | if ( ev.keyCode === 38 ){ 67 | setPlayerY( y => y-1 ) 68 | setPlayerH(false) 69 | } 70 | if ( ev.keyCode === 39 ){ 71 | setPlayerX( x => x+1 ) 72 | setPlayerH(false) 73 | } 74 | if ( ev.keyCode === 40 ){ 75 | setPlayerY( y => y+1 ) 76 | setPlayerH(false) 77 | } 78 | if( ev.keyCode === 72 ){ 79 | setPlayerH(h => !h) 80 | } 81 | } 82 | } 83 | 84 | const handleReceivedMessageEvent = evt => { 85 | setLastReceived(evt.data) 86 | 87 | try { 88 | 89 | const updateMsg = JSON.parse(evt.data) 90 | 91 | if ( updateMsg.messageType === 'player' ){ 92 | 93 | setPlayerInitialized(true); 94 | 95 | // Received update on some player through the 'world' websocket 96 | const thatPlayerID = updateMsg.playerID 97 | setPlayerMap(plMap => { 98 | const newPlMap = updatePositionMapValue(plMap, thatPlayerID, updateMsg.payload) 99 | return newPlMap 100 | }) 101 | // We compare generations before receiving an update to self, to avoid update loops 102 | // from player updates delivered back to us asynchronously: 103 | if ((thatPlayerID === playerID) && (updateMsg.payload.generation >= generationRef.current - 1)){ 104 | setPlayerX(updateMsg.payload.x) 105 | setPlayerY(updateMsg.payload.y) 106 | setPlayerH(updateMsg.payload.h) 107 | } 108 | } else if ( updateMsg.messageType === 'leaving' ) { 109 | // some player is leaving the arena: let us update our knowledge of the game field 110 | const thatPlayerID = updateMsg.playerID 111 | setPlayerMap(plMap => { 112 | const newPlMap = removePlayerMapEntry(plMap, thatPlayerID) 113 | return newPlMap 114 | }) 115 | } else if ( updateMsg.messageType === 'geometry' ) { 116 | // hooray: we just received initial geometry info from the API! 117 | setHalfSizeX(updateMsg.payload.halfSizeX) 118 | setHalfSizeY(updateMsg.payload.halfSizeY) 119 | } else if ( updateMsg.messageType === 'chat' ) { 120 | // we received a new chat item. Let's make room for it 121 | // first we add the playerID to the chat-item object for local storing 122 | const chatPayload = {...updateMsg.payload, ...{playerID: updateMsg.playerID}} 123 | // then we concatenate it to the items to display (discarding the oldest if necessary) 124 | setChatItems( items => items.concat([chatPayload]).slice(-settings.MAX_ITEMS_IN_CHAT) ) 125 | } else if ( updateMsg.messageType === 'brick' ) { 126 | // we add a brick to the list of known brick positions 127 | // We should make sure we uniquify the list (w.r.t coordinates, for example) ... 128 | setBrickList( bs => bs.concat([updateMsg.payload]) ) 129 | } else if ( updateMsg.messageType === 'food' ) { 130 | // whether new or moved around, let us mark the new position for this 131 | // piece of food 132 | const foodID = updateMsg.foodID 133 | setFoodMap(fMap => { 134 | const newFMap = updatePositionMapValue(fMap, foodID, updateMsg.payload) 135 | return newFMap 136 | }) 137 | } else { 138 | // another messageType 139 | console.log(`Ignoring messageType = ${updateMsg.messageType} ... for now`) 140 | } 141 | 142 | } catch (e) { 143 | console.log(`Error "${e}" while receiving message "${evt.data}". Ignoring message, the show must go on`) 144 | } 145 | 146 | } 147 | 148 | useEffect( () => { 149 | if(inGame){ 150 | 151 | if(wws === null || pws === null){ 152 | // we must create the websockets and attach callbacks to them 153 | 154 | wws = new WebSocket(`${apiLocation}/ws/world/${playerID}`) 155 | pws = new WebSocket(`${apiLocation}/ws/player/${playerID}`) 156 | 157 | pws.onopen = evt => { 158 | // let's ask for init data 159 | const msg2 = packEnteringMessage(playerName) 160 | pws.send(msg2) 161 | setLastSent(msg2) 162 | } 163 | 164 | // both sockets can receive messages and should treat them the same way 165 | wws.onmessage = handleReceivedMessageEvent 166 | pws.onmessage = handleReceivedMessageEvent 167 | } else { 168 | // socket already opened: can be used immediately 169 | // let's ask for init data 170 | const msg2 = packEnteringMessage(playerName) 171 | pws.send(msg2) 172 | setLastSent(msg2) 173 | } 174 | 175 | 176 | } else { 177 | if(wws !== null || pws !== null){ 178 | 179 | // we notify the API that we are leaving 180 | if(pws && pws.readyState === 1){ 181 | const msg = packLeavingMessage(playerName) 182 | setLastSent(msg) 183 | pws.send(msg) 184 | } 185 | setGeneration( g => g+1 ) 186 | 187 | // it is time to disconnect the websockets 188 | if(wws === null){ 189 | wws.disconnect() 190 | wws = null; 191 | } 192 | if(pws === null){ 193 | pws.disconnect() 194 | pws = null; 195 | } 196 | } 197 | } 198 | // eslint-disable-next-line 199 | }, [inGame]) 200 | 201 | const sendChatItem = text => { 202 | const ttext = text.trim() 203 | if (ttext !== '' && pws && pws.readyState === 1){ 204 | const msg = packChatMessage(playerName, text) 205 | setLastSent(msg) 206 | pws.send(msg) 207 | } 208 | } 209 | 210 | useEffect( () => { 211 | if (inGame) { 212 | 213 | if(playerInitialized && pws && pws.readyState === 1){ 214 | const msg = packPlayerMessage(playerX, playerY, playerH, generationRef.current, playerName) 215 | setLastSent(msg) 216 | pws.send(msg) 217 | } // else: hmm, we seem not to have a working socket AND a player on the field. We'll not send out (!) 218 | 219 | // we increment the generation number to recognize and ignore 'stale' player updates bouncing back to us 220 | setGeneration( g => g+1 ) 221 | 222 | } 223 | // we are handling generation increase explicitly within this hook, so we don't react to 'generation': 224 | // eslint-disable-next-line 225 | }, [inGame, playerName, playerX, playerY, playerH]) 226 | 227 | return ( 228 |
229 |
230 | 244 |
245 | {inGame && } 267 |
268 | ); 269 | } 270 | 271 | export default App; 272 | -------------------------------------------------------------------------------- /client/src/components/ChatArea.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | 3 | const ChatArea = ({chatItems, sendChatItem, playerID}) => { 4 | 5 | const [chatText, setChatText] = useState('') 6 | 7 | return (
8 |
In-game chat
9 |
    10 | {chatItems.map( ci => ( 11 |
  • 12 | 15 | {ci.name} 16 | : {ci.text} 17 |
  • 18 | ))} 19 |
20 | setChatText(e.target.value)} 25 | onKeyDown={(e) => { 26 | if(e.keyCode === 13){ 27 | sendChatItem(chatText) 28 | setChatText('') 29 | } 30 | } } 31 | /> 32 |
) 33 | } 34 | 35 | export default ChatArea; 36 | -------------------------------------------------------------------------------- /client/src/components/GameArea.js: -------------------------------------------------------------------------------- 1 | import GameInputs from "./GameInputs" 2 | import GameField from "./GameField" 3 | 4 | const GameArea = (props) => { 5 | 6 | const playerName = props.playerName 7 | const playerID = props.playerID 8 | const playerMap = props.playerMap 9 | const brickList = props.brickList 10 | const foodMap = props.foodMap 11 | const playerX = props.playerX 12 | const setPlayerX = props.setPlayerX 13 | const playerY = props.playerY 14 | const setPlayerY = props.setPlayerY 15 | const boardWidth = props.boardWidth 16 | const boardHeight = props.boardHeight 17 | const handleKeyDown = props.handleKeyDown 18 | const lastSent = props.lastSent 19 | const lastReceived = props.lastReceived 20 | const setPlayerH = props.setPlayerH 21 | const chatItems = props.chatItems 22 | const sendChatItem = props.sendChatItem 23 | 24 | return ( 25 |
26 |
27 |
28 | 41 |
42 |
43 | 51 |
52 |
53 |
54 |

Last sent: 55 | {lastSent} 56 |

57 |

Last received: 58 | {lastReceived} 59 |

60 |
61 |
62 | ); 63 | 64 | } 65 | 66 | export default GameArea; 67 | -------------------------------------------------------------------------------- /client/src/components/GameField.js: -------------------------------------------------------------------------------- 1 | const GameField = ({playerMap, brickList, foodMap, playerID, boardWidth, boardHeight}) => { 2 | 3 | return ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | { brickList.map( (brickInfo) => { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | })} 30 | { Object.entries(foodMap).map( ([foodID, foodInfo]) => { 31 | return ( 32 | 33 | 34 | {foodInfo.h && } 35 | 36 | ) 37 | })} 38 | 39 | { Object.entries(playerMap).map( ([thatPlayerID, thatPlayerInfo]) => { 40 | const patternName = thatPlayerID === playerID ? 'lyco_self' : 'lyco_other' 41 | const playerClassName = thatPlayerID === playerID ? 'player-self' : 'player-other' 42 | return ( 43 | 44 | 45 | {thatPlayerInfo.h && } 46 | = boardHeight? -70 : 70})`}> 47 | 48 | {thatPlayerInfo.name} 49 | 50 | 51 | 52 | ) 53 | })} 54 | 55 | ) 56 | } 57 | 58 | export default GameField; 59 | -------------------------------------------------------------------------------- /client/src/components/GameInputs.js: -------------------------------------------------------------------------------- 1 | import ChatArea from "./ChatArea" 2 | 3 | const GameInputs = ({playerName, playerID, playerX, setPlayerX, playerY, setPlayerY, setPlayerH, chatItems, sendChatItem, boardWidth, boardHeight}) => { 4 | 5 | return (
6 |
7 | Position: ({playerX}, {playerY}) 8 | / ({boardWidth - 1}, {boardHeight - 1}) 9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 | Powered by 31 |
32 |
33 | 34 | Astra Streaming 35 | 36 |
37 | 42 |
) 43 | } 44 | 45 | export default GameInputs; 46 | 47 | -------------------------------------------------------------------------------- /client/src/components/PlayerForm.js: -------------------------------------------------------------------------------- 1 | const PlayerForm = (props) => { 2 | 3 | const apiLocation = props.apiLocation 4 | const setApiLocation = props.setApiLocation 5 | const playerName = props.playerName 6 | const setPlayerName = props.setPlayerName 7 | const inGame = props.inGame 8 | const setInGame = props.setInGame 9 | const setPlayerInitialized = props.setPlayerInitialized 10 | const setPlayerMap = props.setPlayerMap 11 | const playerID = props.playerID 12 | const setLastSent = props.setLastSent 13 | const setLastReceived = props.setLastReceived 14 | const setGeneration = props.setGeneration 15 | 16 | return (
17 | {!inGame &&
18 |

19 | Your name: 20 | setPlayerName(e.target.value)} 25 | /> 26 |

27 |

28 | Your ID: 29 | 30 |

31 |

32 | API Location: 33 | setApiLocation(e.target.value)} 38 | /> 39 |

40 | 50 |
} 51 | {inGame &&
52 |
53 | Drapetisca 54 | {playerName} 55 | 58 |
59 |
} 60 |
) 61 | 62 | } 63 | 64 | export default PlayerForm 65 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ) 12 | -------------------------------------------------------------------------------- /client/src/settings.js: -------------------------------------------------------------------------------- 1 | // game client settings 2 | 3 | module.exports = Object.freeze({ 4 | DEFAULT_API_LOCATION: 'ws://localhost:8000', 5 | MAX_ITEMS_IN_CHAT: 10, 6 | }) 7 | -------------------------------------------------------------------------------- /client/src/utils/guessAPILocation.js: -------------------------------------------------------------------------------- 1 | // we try to build the API location from the client's URL 2 | // (in a rather crude manner) 3 | const guessAPILocation = (clientURL, defaultURL) => { 4 | if ((clientURL.indexOf('3000') >= 0) && (clientURL.indexOf('http') >= 0)){ 5 | 6 | // this cleanup is to make the guess work within Gitpod, where the "(mini-)browser" reports an URL 7 | // such as "wss://8000-apricot-lizard-sa8u19n5.ws-eu17.gitpod.io/?vscodeBrowserReqId=1635860969945" 8 | // (as opposed to "ordinary" browsers where the URL would end with "...gitpod.io/") 9 | const gpCleanedClientURL = clientURL.indexOf('?') >= 0 ? clientURL.slice(0, clientURL.indexOf('?')) : clientURL 10 | 11 | // now the 'standard' guesswork can start 12 | const apiLoc = gpCleanedClientURL.replace('3000', '8000').replace('http', 'ws') 13 | if (apiLoc[apiLoc.length - 1] === '/'){ 14 | // get rid of trailing '/' 15 | return apiLoc.slice(0, -1) 16 | }else{ 17 | return apiLoc 18 | } 19 | }else{ 20 | return defaultURL 21 | } 22 | } 23 | 24 | export default guessAPILocation 25 | -------------------------------------------------------------------------------- /client/src/utils/messages.js: -------------------------------------------------------------------------------- 1 | const uuid = require('uuid'); 2 | 3 | // utility to serialize a standard player info message (name, position, ...) 4 | export const packPlayerMessage = (x, y, h, generation, name) => { 5 | return JSON.stringify({ 6 | messageType: 'player', 7 | payload: { 8 | generation, 9 | name, 10 | x, 11 | y, 12 | h 13 | } 14 | }) 15 | } 16 | 17 | // utility to serialize a player chat entry 18 | export const packChatMessage = (name, text) => { 19 | return JSON.stringify({ 20 | messageType: 'chat', 21 | payload: { 22 | name, 23 | text, 24 | id: uuid.v4(), 25 | } 26 | }) 27 | } 28 | 29 | export const packEnteringMessage = (name) => { 30 | return JSON.stringify({ 31 | messageType: 'entering', 32 | payload: { 33 | name, 34 | } 35 | }) 36 | } 37 | 38 | export const packLeavingMessage = (name) => { 39 | return JSON.stringify({ 40 | messageType: 'leaving', 41 | payload: { 42 | name, 43 | } 44 | }) 45 | } -------------------------------------------------------------------------------- /client/src/utils/names.js: -------------------------------------------------------------------------------- 1 | // utility to draw a random spider name 2 | 3 | // List of spider genera in Italy 4 | // according to [Pantini, Isaia 2019] 5 | const spiderNames = [ 6 | 'Acantholycosa', 7 | 'Acartauchenius', 8 | 'Aculepeira', 9 | 'Agalenatea', 10 | 'Agelena', 11 | 'Agnyphantes', 12 | 'Agraecina', 13 | 'Agroeca', 14 | 'Agyneta', 15 | 'Alioranus', 16 | 'Allagelena', 17 | 'Allomengea', 18 | 'Alopecosa', 19 | 'Altella', 20 | 'Amaurobius', 21 | 'Amblyocarenum', 22 | 'Anguliphantes', 23 | 'Antistea', 24 | 'Anyphaena', 25 | 'Aphantaulax', 26 | 'Aphileta', 27 | 'Apostenus', 28 | 'Araeoncus', 29 | 'Araneus', 30 | 'Araniella', 31 | 'Archaeodictyna', 32 | 'Argenna', 33 | 'Argiope', 34 | 'Argyroneta', 35 | 'Asthenargus', 36 | 'Aterigena', 37 | 'Atypus', 38 | 'Baryphyma', 39 | 'Bathyphantes', 40 | 'Berlandina', 41 | 'Bolephthyphantes', 42 | 'Bolyphantes', 43 | 'Brigittea', 44 | 'Brommella', 45 | 'Callilepis', 46 | 'Callobius', 47 | 'Camillina', 48 | 'Canariphantes', 49 | 'Caracladus', 50 | 'Caviphantes', 51 | 'Centromerita', 52 | 'Centromerus', 53 | 'Ceratinella', 54 | 'Cercidia', 55 | 'Cheiracanthium', 56 | 'Cicurina', 57 | 'Cinetata', 58 | 'Civizelotes', 59 | 'Clubiona', 60 | 'Cnephalocotes', 61 | 'Coelotes', 62 | 'Collinsia', 63 | 'Comaroma', 64 | 'Cresmatoneta', 65 | 'Crosbyarachne', 66 | 'Cryphoeca', 67 | 'Cryptodrassus', 68 | 'Cteniza', 69 | 'Cybaeodes', 70 | 'Cybaeus', 71 | 'Cyclosa', 72 | 'Cyrtarachne', 73 | 'Cyrtophora', 74 | 'Dasumia', 75 | 'Dictyna', 76 | 'Dicymbium', 77 | 'Diplocentria', 78 | 'Diplocephalus', 79 | 'Diplostyla', 80 | 'Dismodicus', 81 | 'Donacochara', 82 | 'Drapetisca', 83 | 'Drassodes', 84 | 'Drassodex', 85 | 'Drassyllus', 86 | 'Drepanotylus', 87 | 'Dysdera', 88 | 'Echemus', 89 | 'Emblyna', 90 | 'Entelecara', 91 | 'Eratigena', 92 | 'Eresus', 93 | 'Erigone', 94 | 'Erigonella', 95 | 'Erigonoplus', 96 | 'Evansia', 97 | 'Filistata', 98 | 'Floronia', 99 | 'Formiphantes', 100 | 'Frontinellina', 101 | 'Gibbaranea', 102 | 'Glyphesis', 103 | 'Glyptogona', 104 | 'Gnaphosa', 105 | 'Gnathonarium', 106 | 'Gonatium', 107 | 'Gongylidiellum', 108 | 'Gongylidium', 109 | 'Hahnia', 110 | 'Hahniharmia', 111 | 'Haplodrassus', 112 | 'Harpactea', 113 | 'Harpactocrates', 114 | 'Helophora', 115 | 'Heser', 116 | 'Hilaira', 117 | 'Histopona', 118 | 'Hybocoptus', 119 | 'Hylyphantes', 120 | 'Hypomma', 121 | 'Hypsocephalus', 122 | 'Hypsosinga', 123 | 'Iberina', 124 | 'Improphantes', 125 | //'Incestophantes', // does not sound very gentle to assign a game player 126 | 'Inermocoelotes', 127 | 'Ipa', 128 | 'Janetschekia', 129 | 'Kaemis', 130 | 'Kaestneria', 131 | 'Kishidaia', 132 | 'Labulla', 133 | 'Larinioides', 134 | 'Lasiargus', 135 | 'Lathys', 136 | 'Lepthyphantes', 137 | 'Leptodrassus', 138 | 'Leptoneta', 139 | 'Leptorhoptrum', 140 | 'Lessertia', 141 | 'Lessertinella', 142 | 'Leviellus', 143 | 'Linyphia', 144 | 'Liocranoeca', 145 | 'Liocranum', 146 | 'Lipocrea', 147 | 'Lophomma', 148 | 'Lycosoides', 149 | 'Macrargus', 150 | 'Maimuna', 151 | 'Mangora', 152 | 'Mansuphantes', 153 | 'Marilynia', 154 | 'Maro', 155 | 'Maso', 156 | 'Mastigusa', 157 | 'Mecopisthes', 158 | 'Mecynargus', 159 | 'Megalepthyphantes', 160 | 'Mermessus', 161 | 'Mesiotelus', 162 | 'Mesostalita', 163 | 'Metopobactrus', 164 | 'Micaria', 165 | 'Micrargus', 166 | 'Microctenonyx', 167 | 'Microlinyphia', 168 | 'Microneta', 169 | 'Midia', 170 | 'Minicia', 171 | 'Minyriolus', 172 | 'Mioxena', 173 | 'Mizaga', 174 | 'Moebelia', 175 | 'Monocephalus', 176 | 'Mughiphantes', 177 | 'Mycula', 178 | 'Nematogmus', 179 | 'Neoscona', 180 | 'Neriene', 181 | 'Nigma', 182 | 'Nomisia', 183 | 'Notioscopus', 184 | 'Nuctenea', 185 | 'Nusoncus', 186 | 'Obscuriphantes', 187 | 'Oedothorax', 188 | 'Oreoneta', 189 | 'Oreonetides', 190 | 'Ostearius', 191 | 'Ouedia', 192 | 'Palliduphantes', 193 | 'Panamomops', 194 | 'Parachtes', 195 | 'Paraleptoneta', 196 | 'Parapelecopsis', 197 | 'Parasyrisca', 198 | 'Pelecopsis', 199 | 'Peponocranium', 200 | 'Phaeocedus', 201 | 'Piniphantes', 202 | 'Pireneitega', 203 | 'Pityohyphantes', 204 | 'Pocadicnemis', 205 | 'Poecilochroa', 206 | 'Poeciloneta', 207 | 'Porrhoclubiona', 208 | 'Porrhomma', 209 | 'Prinerigone', 210 | 'Pritha', 211 | 'Protoleptoneta', 212 | 'Pseudomaro', 213 | 'Rhode', 214 | 'Saaristoa', 215 | 'Sagana', 216 | 'Saloca', 217 | 'Sardostalita', 218 | 'Sauron', 219 | 'Sciastes', 220 | 'Scotargus', 221 | 'Scotina', 222 | 'Scotinotylus', 223 | 'Scotophaeus', 224 | 'Scutpelecopsis', 225 | 'Semljicola', 226 | 'Setaphis', 227 | 'Silometopus', 228 | 'Singa', 229 | 'Sintula', 230 | 'Sisicus', 231 | 'Sosticus', 232 | 'Stalita', 233 | 'Stegodyphus', 234 | 'Stemonyphantes', 235 | 'Styloctetor', 236 | 'Syedra', 237 | 'Synaphosus', 238 | 'Tallusia', 239 | 'Tapinocyba', 240 | 'Tapinocyboides', 241 | 'Tapinopa', 242 | 'Tegenaria', 243 | 'Tenuiphantes', 244 | 'Textrix', 245 | 'Theonina', 246 | 'Thyreosthenius', 247 | 'Tiso', 248 | 'Trachyzelotes', 249 | 'Trematocephalus', 250 | 'Trichoncoides', 251 | 'Trichoncus', 252 | 'Trichoncyboides', 253 | 'Trichopterna', 254 | 'Trichopternoides', 255 | 'Troglohyphantes', 256 | 'Troxochrus', 257 | 'Tuberta', 258 | 'Turinyphia', 259 | 'Typhochrestus', 260 | 'Urocoras', 261 | 'Urozelotes', 262 | 'Walckenaeria', 263 | 'Wiehlenarius', 264 | 'Zangherella', 265 | 'Zelotes', 266 | 'Zilla', 267 | 'Zimirina', 268 | 'Zygiella' 269 | ] 270 | 271 | const getRandomSpiderName = () => { 272 | return spiderNames[Math.floor(spiderNames.length * Math.random())] 273 | } 274 | 275 | export default getRandomSpiderName 276 | 277 | -------------------------------------------------------------------------------- /client/src/utils/playerMaps.js: -------------------------------------------------------------------------------- 1 | // utility to upsert a new key-value pair into an object 2 | export const updatePositionMapValue = (origMap, repKey, newValue) => { 3 | const newObj = origMap 4 | newObj[repKey] = newValue 5 | // we take out players with null x or y values. This happens in the transient case 6 | // of the onboarding of a player, when setting x and y occurs sequentially: 7 | // for a brief moment one is set and the other is still null). 8 | // Doing this saves some headache in the rendering code. 9 | return Object.fromEntries(Object.entries(newObj).filter( ([k,v]) => v.x !== null && v.y !== null)) 10 | } 11 | 12 | export const removePlayerMapEntry = (origMap, delKey) => { 13 | return Object.fromEntries(Object.entries(origMap).filter( ([k,v]) => k !== delKey)) 14 | } 15 | -------------------------------------------------------------------------------- /images/Theridion_grallator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/Theridion_grallator.png -------------------------------------------------------------------------------- /images/astra-create-token.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/astra-create-token.gif -------------------------------------------------------------------------------- /images/astra-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/astra-token.png -------------------------------------------------------------------------------- /images/astra_bundle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/astra_bundle.png -------------------------------------------------------------------------------- /images/astra_create_db.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/astra_create_db.gif -------------------------------------------------------------------------------- /images/astra_create_streaming_topic_v2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/astra_create_streaming_topic_v2.gif -------------------------------------------------------------------------------- /images/astra_signup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/astra_signup.gif -------------------------------------------------------------------------------- /images/cql_console.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/cql_console.gif -------------------------------------------------------------------------------- /images/create_astra_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/create_astra_button.png -------------------------------------------------------------------------------- /images/create_database_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/create_database_button.png -------------------------------------------------------------------------------- /images/drag-and-drop-bundle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/drag-and-drop-bundle.png -------------------------------------------------------------------------------- /images/drapetisca_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/drapetisca_2.png -------------------------------------------------------------------------------- /images/drapetisca_3_v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/drapetisca_3_v2.png -------------------------------------------------------------------------------- /images/drapetisca_homework.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/drapetisca_homework.png -------------------------------------------------------------------------------- /images/drapetisca_intro_v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/drapetisca_intro_v2.png -------------------------------------------------------------------------------- /images/eavesdrop_streaming.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/eavesdrop_streaming.gif -------------------------------------------------------------------------------- /images/gitpod_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/gitpod_view.png -------------------------------------------------------------------------------- /images/open_in_gitpod_button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /images/orgsettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/orgsettings.png -------------------------------------------------------------------------------- /images/ref_code_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/ref_code_1.png -------------------------------------------------------------------------------- /images/streaming-workshop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/streaming-workshop.png -------------------------------------------------------------------------------- /images/streaming_secrets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/streaming_secrets.png -------------------------------------------------------------------------------- /images/try-me-demo-video-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/images/try-me-demo-video-thumbnail.png -------------------------------------------------------------------------------- /slides/DataStaxDevs-workshop-Build_a_Multiplayer_Game_with_Streaming.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datastaxdevs/workshop-streaming-game/ac32d2de25459ac4506eba948a1fbc9a66e90a93/slides/DataStaxDevs-workshop-Build_a_Multiplayer_Game_with_Streaming.pdf --------------------------------------------------------------------------------