├── app ├── .gitignore ├── .dockerignore ├── Dockerfile ├── lib │ ├── storage │ │ ├── redis.js │ │ ├── bootstrap.js │ │ ├── stats.js │ │ └── games.js │ ├── sockets.js │ ├── stats-feed.js │ ├── ws-connection.js │ └── game.js ├── package.json └── index.js ├── nginx ├── .gitignore ├── Dockerfile └── default.conf.template ├── .gitignore ├── db-schema ├── .dockerignore ├── Dockerfile ├── package.json └── index.js ├── frontend ├── .dockerignore ├── .gitignore ├── src │ ├── x.png │ ├── grid.png │ ├── src │ │ ├── sass │ │ │ ├── _variables.scss │ │ │ ├── main.scss │ │ │ ├── _mixins.scss │ │ │ ├── _butterbar.scss │ │ │ ├── _footer.scss │ │ │ └── _basics.scss │ │ └── js │ │ │ ├── app.js │ │ │ ├── socket.js │ │ │ ├── stats │ │ │ ├── feed.js │ │ │ └── table.js │ │ │ ├── game.js │ │ │ └── renderer.js │ ├── circle.png │ ├── favicon.ico │ ├── carina-logo.png │ ├── tic-tac-toe.ai │ ├── circle.svg │ ├── x.svg │ ├── grid.svg │ └── index.html ├── script │ ├── lib │ │ ├── root-dir.js │ │ ├── do-webpack.js │ │ ├── do-sass.js │ │ └── webpack-config.js │ ├── compile-assets │ └── watch-assets ├── Dockerfile └── package.json ├── blog ├── web-ui.png ├── game-stack.png ├── tic-tac-toe-stats.gif └── README.md ├── manifests ├── 00_namespace.yaml ├── 06_certificates.yaml ├── 05_ingress.yaml ├── 02_redis.yaml ├── 04_frontend.yaml ├── 03_app.yaml └── 01_rethinkdb.yaml ├── script ├── build-app ├── build-nginx-proxy ├── create-network ├── update-frontend ├── update-db-schema ├── create-db-data ├── create-redis-data ├── start-redis ├── start-db ├── create-frontend-data ├── create-nginx-config-data ├── start-bots ├── create-letsencrypt-data ├── create-htpasswd-data ├── start-app ├── start-nginx-proxy ├── setup └── update-nginx ├── bot ├── Dockerfile ├── package.json └── index.js ├── cron ├── Dockerfile └── reissue ├── env.example ├── docker-compose.yml ├── package.json └── README.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /nginx/.gitignore: -------------------------------------------------------------------------------- 1 | default.conf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | env 3 | -------------------------------------------------------------------------------- /app/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /db-schema/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src/dist 3 | -------------------------------------------------------------------------------- /blog/web-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/blog/web-ui.png -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | COPY default.conf /etc/nginx/conf.d/default.conf 4 | -------------------------------------------------------------------------------- /frontend/src/x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/frontend/src/x.png -------------------------------------------------------------------------------- /blog/game-stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/blog/game-stack.png -------------------------------------------------------------------------------- /frontend/src/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/frontend/src/grid.png -------------------------------------------------------------------------------- /frontend/src/src/sass/_variables.scss: -------------------------------------------------------------------------------- 1 | $text-color: #444; 2 | $red: #CC523E; 3 | $blue: #698DCC; 4 | -------------------------------------------------------------------------------- /frontend/src/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/frontend/src/circle.png -------------------------------------------------------------------------------- /manifests/00_namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: tic-tac-toe 5 | -------------------------------------------------------------------------------- /blog/tic-tac-toe-stats.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/blog/tic-tac-toe-stats.gif -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/frontend/src/favicon.ico -------------------------------------------------------------------------------- /frontend/src/carina-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/frontend/src/carina-logo.png -------------------------------------------------------------------------------- /frontend/src/tic-tac-toe.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktbartholomew/tic-tac-toe/HEAD/frontend/src/tic-tac-toe.ai -------------------------------------------------------------------------------- /frontend/script/lib/root-dir.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = path.resolve(__dirname, '../..'); 4 | -------------------------------------------------------------------------------- /frontend/src/src/sass/main.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "mixins"; 3 | @import "basics"; 4 | @import "butterbar"; 5 | @import "footer"; 6 | -------------------------------------------------------------------------------- /frontend/src/src/sass/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin clearfix() { 2 | &:after { 3 | clear: both; 4 | content: " "; 5 | display: block; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /script/build-app: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | root=$(cd $(dirname ${0})/..; pwd) 5 | 6 | docker build \ 7 | -t ttt_app \ 8 | ${root}/app 9 | -------------------------------------------------------------------------------- /bot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:4.2.6 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package.json /usr/src/app/ 6 | RUN npm install 7 | COPY . /usr/src/app/ 8 | 9 | CMD node index.js 10 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.0.0 2 | 3 | WORKDIR /usr/src/app 4 | 5 | EXPOSE 8080 6 | 7 | COPY package.json /usr/src/app/ 8 | 9 | RUN npm install 10 | 11 | COPY . /usr/src/app/ 12 | 13 | CMD node index.js 14 | -------------------------------------------------------------------------------- /db-schema/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.0.0 2 | 3 | WORKDIR /usr/src/app 4 | 5 | EXPOSE 8080 6 | 7 | COPY package.json /usr/src/app/ 8 | 9 | RUN npm install 10 | 11 | COPY index.js /usr/src/app/ 12 | 13 | CMD node index.js 14 | -------------------------------------------------------------------------------- /app/lib/storage/redis.js: -------------------------------------------------------------------------------- 1 | var redis = require('redis'); 2 | 3 | module.exports = { 4 | getClient: function () { 5 | return redis.createClient({ 6 | host: process.env.REDIS_HOST || 'redis' 7 | }); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /script/build-nginx-proxy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | root=$(cd $(dirname ${0})/..; pwd) 5 | 6 | docker build \ 7 | --build-arg affinity:container==ttt_nginx_config_data \ 8 | -t ttt_nginx_proxy \ 9 | ${root}/nginx 10 | -------------------------------------------------------------------------------- /script/create-network: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | if [[ $(docker network inspect tictactoe 2> /dev/null) == "null" ]]; then 5 | docker network create \ 6 | --driver overlay \ 7 | --subnet 192.168.0.0/24 \ 8 | tictactoe 9 | fi 10 | -------------------------------------------------------------------------------- /script/update-frontend: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | root=$(cd $(dirname ${0})/..; pwd) 5 | 6 | NODE_ENV=production ${root}/frontend/script/compile-assets 7 | 8 | cd ${root}/frontend/src 9 | 10 | docker cp ./ ttt_frontend_data:/usr/share/nginx/html/ 11 | -------------------------------------------------------------------------------- /script/update-db-schema: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ueo pipefail 4 | 5 | root=$(cd $(dirname ${0})/..; pwd) 6 | 7 | docker build -t ttt_db_schema ${root}/db-schema 8 | 9 | docker run \ 10 | --rm \ 11 | -it \ 12 | --net tictactoe \ 13 | -e DB_HOST=ttt_db \ 14 | ttt_db_schema 15 | -------------------------------------------------------------------------------- /script/create-db-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | container_exists=$(docker ps -a -q -f name=ttt_db_data) 5 | 6 | if [[ -z ${container_exists} ]]; then 7 | docker run \ 8 | --name ttt_db_data \ 9 | --volume /data \ 10 | rethinkdb \ 11 | /bin/true 12 | fi 13 | -------------------------------------------------------------------------------- /script/create-redis-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | container_exists=$(docker ps -a -q -f name=ttt_redis_data) 5 | 6 | if [[ -z ${container_exists} ]]; then 7 | docker run \ 8 | --name ttt_redis_data \ 9 | --volume /data \ 10 | redis \ 11 | /bin/true 12 | fi 13 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.0.0 as builder 2 | 3 | RUN mkdir -p /build 4 | 5 | WORKDIR /build 6 | 7 | ADD . /build 8 | 9 | RUN npm install 10 | 11 | RUN NODE_ENV=production npm run build 12 | 13 | ### 14 | 15 | FROM nginx:latest 16 | COPY --from=builder /build/src /usr/share/nginx/html 17 | -------------------------------------------------------------------------------- /frontend/script/compile-assets: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var async = require('async'); 4 | 5 | var doSass = require('./lib/do-sass'); 6 | var doWebpack = require('./lib/do-webpack'); 7 | 8 | async.parallel([ 9 | doSass, 10 | doWebpack 11 | ], function (err, result) { 12 | if (err) { 13 | throw err; 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /cron/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.3 2 | 3 | RUN apk add --no-cache docker 4 | 5 | COPY reissue /etc/periodic/monthly/reissue 6 | RUN chmod a+x /etc/periodic/monthly/reissue 7 | 8 | # Run the cron daemon with the following flags: 9 | # -f: Foreground 10 | # -d 8: Log to stderr, use default log level 11 | CMD ["/usr/sbin/crond", "-f", "-d", "8"] 12 | -------------------------------------------------------------------------------- /app/lib/sockets.js: -------------------------------------------------------------------------------- 1 | var Redis = require('./storage/redis'); 2 | var redisClient = Redis.getClient(); 3 | var Sockets = {}; 4 | 5 | Object.defineProperty(Sockets, 'publish', { 6 | value: function (socketId, message) { 7 | redisClient.publish('sockets:' + socketId, JSON.stringify(message)); 8 | } 9 | }); 10 | 11 | module.exports = Sockets; 12 | -------------------------------------------------------------------------------- /script/start-redis: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | if [[ $(docker inspect -f {{.ID}} ttt_redis 2> /dev/null) != "" ]]; then 5 | docker rm -f ttt_redis 6 | fi 7 | 8 | docker run \ 9 | --detach \ 10 | --name ttt_redis \ 11 | --env affinity:container==ttt_redis_data \ 12 | --net tictactoe \ 13 | --volumes-from ttt_redis_data \ 14 | redis 15 | -------------------------------------------------------------------------------- /script/start-db: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | if [[ $(docker inspect -f {{.ID}} ttt_db 2> /dev/null) != "" ]]; then 5 | docker rm -f ttt_db 6 | fi 7 | 8 | docker run \ 9 | --detach \ 10 | --name ttt_db \ 11 | --env affinity:container==ttt_db_data \ 12 | --net tictactoe \ 13 | --restart always \ 14 | --volumes-from ttt_db_data \ 15 | rethinkdb 16 | -------------------------------------------------------------------------------- /script/create-frontend-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | container_exists=$(docker ps -a -q -f name=ttt_frontend_data) 5 | 6 | if [[ -z ${container_exists} ]]; then 7 | docker run \ 8 | --name ttt_frontend_data \ 9 | --env affinity:container==ttt_nginx_config_data \ 10 | --volume /usr/share/nginx/html \ 11 | nginx \ 12 | /bin/true 13 | fi 14 | -------------------------------------------------------------------------------- /script/create-nginx-config-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | container_exists=$(docker ps -a -q -f name=ttt_nginx_config_data) 5 | 6 | if [[ -z ${container_exists} ]]; then 7 | docker run \ 8 | --name ttt_nginx_config_data \ 9 | --env constraint:node==/${NGINX_NODE_PATTERN}/ \ 10 | --volume /etc/nginx/conf.d \ 11 | nginx \ 12 | /bin/true 13 | fi 14 | -------------------------------------------------------------------------------- /bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bot", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Keith Bartholomew ", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "rethinkdb": "^2.2.2", 13 | "ws": "^1.0.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /db-schema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tic-tac-toe-db-schema", 3 | "version": "1.0.0", 4 | "description": "Builds the DB schema for tic-tac-toe game", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Keith Bartholomew ", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "rethinkdb": "2.3.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /script/start-bots: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | if [ -z ${1} ]; then 5 | echo "Usage: $(basename ${0}) " 6 | exit 1 7 | fi 8 | 9 | docker ps -q -f name=ttt_bot_ | xargs docker rm -f 10 | 11 | for i in $(seq 1 ${1:-1}); do 12 | docker run \ 13 | -d \ 14 | --name=ttt_bot_${i} \ 15 | -e affinity:image==ttt_bot \ 16 | -e DB_HOST=ttt_db \ 17 | --net tictactoe \ 18 | --restart=always \ 19 | ttt_bot 20 | done; 21 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # A pattern matching the name of the Swarm node NGINX should be scheduled to. 2 | export NGINX_NODE_PATTERN= 3 | 4 | # Whether or not to use SSL. 0 = no SSL. 1 = yes SSL. 5 | export NGINX_SSL= 6 | 7 | # The domain visitors use to access. Used to find/request the right certificates 8 | # from Let's Encrypt. 9 | export NGINX_DOMAIN= 10 | 11 | # The password to protect the rethinkdb web console. Leave blank to disable access. 12 | export NGINX_RETHINKDB_PASS= 13 | -------------------------------------------------------------------------------- /manifests/06_certificates.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: certmanager.k8s.io/v1alpha1 2 | kind: Certificate 3 | metadata: 4 | name: tic-tac-toe-tls 5 | namespace: tic-tac-toe 6 | spec: 7 | acme: 8 | config: 9 | - domains: 10 | - tictactoe.keithbartholomew.com 11 | http01: 12 | ingressClass: nginx 13 | commonName: tictactoe.keithbartholomew.com 14 | issuerRef: 15 | kind: ClusterIssuer 16 | name: letsencrypt-prod 17 | secretName: tic-tac-toe-tls 18 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tic-tac-toe-api", 3 | "version": "1.0.0", 4 | "description": "API for tic-tac-toe game", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Keith Bartholomew ", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "uuid": "3.3.2", 13 | "redis": "2.8.0", 14 | "rethinkdb": "2.3.3", 15 | "ws": "7.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/src/js/app.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | var socket = require('./socket'); 4 | var Game = require('./game'); 5 | var StatsFeed = require('./stats/feed'); 6 | var StatsTable = require('./stats/table'); 7 | 8 | var activeGame = new Game({ 9 | socket: socket, 10 | container: document.getElementById('game-container') 11 | }); 12 | 13 | ReactDOM.render(, document.getElementById('stats-table')); 14 | -------------------------------------------------------------------------------- /script/create-letsencrypt-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | container_exists=$(docker ps -a -q -f name=ttt_letsencrypt_data) 5 | 6 | if [[ -z ${container_exists} ]]; then 7 | docker run \ 8 | --name ttt_letsencrypt_data \ 9 | --env affinity:container==ttt_nginx_config_data \ 10 | --volume /etc/letsencrypt \ 11 | --volume /var/lib/letsencrypt \ 12 | --entrypoint /bin/mkdir \ 13 | quay.io/letsencrypt/letsencrypt \ 14 | -p /etc/letsencrypt/webrootauth/ 15 | fi 16 | -------------------------------------------------------------------------------- /app/lib/stats-feed.js: -------------------------------------------------------------------------------- 1 | var Sockets = require('./sockets'); 2 | var StatStorage = require('./storage/stats'); 3 | 4 | module.exports = { 5 | init: function () { 6 | StatStorage.subscribe(function (err, change) { 7 | if (err) { 8 | console.log(err); 9 | return; 10 | } 11 | 12 | for(var socketId in Sockets) { 13 | Sockets.publish(socketId, { 14 | action: 'statsUpdate', 15 | data: [change.new_val] 16 | }); 17 | } 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | var uuid = require('uuid'); 2 | var WebSocketServer = require('ws').Server; 3 | 4 | var WSConnection = require('./lib/ws-connection'); 5 | var Sockets = require('./lib/sockets'); 6 | var StatsFeed = require('./lib/stats-feed'); 7 | 8 | var server = new WebSocketServer({port: 8080}); 9 | 10 | server.on('connection', function connection(client) { 11 | var clientId = uuid.v4(); 12 | Sockets[clientId] = new WSConnection({ 13 | id: clientId, 14 | socket: client 15 | }); 16 | }); 17 | 18 | StatsFeed.init(); 19 | -------------------------------------------------------------------------------- /cron/reissue: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -euo pipefail 3 | 4 | # Re-issue the certificate. 5 | docker run \ 6 | --rm \ 7 | --volumes-from ttt_letsencrypt_data \ 8 | quay.io/letsencrypt/letsencrypt certonly \ 9 | --domain tictac.io \ 10 | --authenticator webroot \ 11 | --webroot-path /etc/letsencrypt/webrootauth/ \ 12 | --email keith.bartholomew@gmail.com \ 13 | --renew-by-default \ 14 | --agree-tos 15 | 16 | # Send NGINX a SIGHUP to trigger it to reload its configuration without shutting down. 17 | docker kill --signal=HUP nginx_proxy 18 | -------------------------------------------------------------------------------- /manifests/05_ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | namespace: tic-tac-toe 5 | name: ingress 6 | spec: 7 | tls: 8 | - secretName: tic-tac-toe-tls 9 | hosts: 10 | - tictactoe.keithbartholomew.com 11 | rules: 12 | - host: tictactoe.keithbartholomew.com 13 | http: 14 | paths: 15 | - path: /live 16 | backend: 17 | serviceName: app 18 | servicePort: 8080 19 | - backend: 20 | serviceName: frontend 21 | servicePort: 80 22 | -------------------------------------------------------------------------------- /script/create-htpasswd-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | password=${NGINX_RETHINKDB_PASS:-""} 5 | container_exists=$(docker ps -a -q -f name=ttt_htpasswd_data) 6 | 7 | if [[ -z ${container_exists} ]]; then 8 | docker run \ 9 | --name ttt_htpasswd_data \ 10 | --env affinity:container==ttt_nginx_config_data \ 11 | --volume /etc/nginx/htpasswd \ 12 | nginx \ 13 | /bin/true 14 | fi 15 | 16 | docker run --rm \ 17 | --volumes-from ttt_htpasswd_data \ 18 | httpd \ 19 | htpasswd -bc /etc/nginx/htpasswd/rethinkdb rethinkdb "${password}" 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | db_data: 2 | image: rethinkdb:2.2.4 3 | volumes: 4 | - /data 5 | command: /bin/true 6 | 7 | db: 8 | image: rethinkdb:2.2.4 9 | ports: 10 | - 9000:8080 11 | volumes_from: 12 | - db_data 13 | 14 | app: 15 | build: api 16 | links: 17 | - db:db 18 | # stdin_open: true 19 | # tty: true 20 | # volumes: 21 | # - ./api:/usr/src/app/ 22 | command: nodemon 23 | 24 | frontend: 25 | build: frontend 26 | links: 27 | - app:app 28 | - db:db 29 | ports: 30 | - 80:80 31 | # volumes: 32 | # - ./frontend/src:/usr/share/nginx/html 33 | -------------------------------------------------------------------------------- /script/start-app: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | scale=${1:-1} 5 | color=${2:-} 6 | 7 | if [[ -z ${color} ]]; then 8 | echo "Usage: $(basename ${0}) " 9 | exit 1 10 | fi 11 | 12 | docker ps -q -f name=ttt_app_${color} -f label=color=${color} | xargs docker rm -f 13 | 14 | for i in $(seq 1 ${scale}); do 15 | docker run \ 16 | -d \ 17 | --label color=${color} \ 18 | --name=ttt_app_${color}_${i} \ 19 | -e affinity:image==ttt_app \ 20 | -e REDIS_HOST=ttt_redis \ 21 | -e RETHINKDB_HOST=ttt_db \ 22 | --net tictactoe \ 23 | --restart=always \ 24 | ttt_app 25 | done; 26 | -------------------------------------------------------------------------------- /frontend/script/lib/do-webpack.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var rootDir = require('./root-dir'); 4 | 5 | module.exports = function (callback) { 6 | callback = callback || function () {}; 7 | 8 | webpack(require('./webpack-config'), function (err, stats) { 9 | if (err) { 10 | console.log(err); 11 | return; 12 | } 13 | 14 | console.log('[doWebpack] Packed %s modules into output file: %s', 15 | stats.compilation.modules.length, 16 | path.resolve(rootDir, 'src/dist/js/app.js') 17 | ); 18 | callback(err, stats); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /app/lib/storage/bootstrap.js: -------------------------------------------------------------------------------- 1 | var r = require('rethinkdb'); 2 | 3 | var Connection; 4 | var DB_NAME = 'tictactoe'; 5 | 6 | module.exports = { 7 | getConnection: function () { 8 | return new Promise(function (resolve, reject) { 9 | if (Connection) { 10 | return resolve(Connection); 11 | } 12 | 13 | return r.connect({ 14 | db: DB_NAME, 15 | host: process.env.RETHINKDB_HOST || 'ttt_db' 16 | }) 17 | .then(function (conn) { 18 | Connection = conn; 19 | 20 | return; 21 | }) 22 | .then(function () { 23 | return resolve(Connection); 24 | }); 25 | }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/script/watch-assets: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var path = require('path'); 4 | var chokidar = require('chokidar'); 5 | var rootDir = require('./lib/root-dir'); 6 | var doSass = require('./lib/do-sass'); 7 | var doWebpack = require('./lib/do-webpack'); 8 | 9 | chokidar.watch(path.resolve(rootDir, 'src/src/sass')) 10 | .on('all', function () { 11 | doSass(function (err, result) { 12 | if (err) { 13 | console.log(err); 14 | } 15 | }); 16 | }); 17 | 18 | chokidar.watch(path.resolve(rootDir, 'src/src/js')) 19 | .on('all', function () { 20 | doWebpack(function (err, result) { 21 | if (err) { 22 | console.log(err); 23 | } 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/src/sass/_butterbar.scss: -------------------------------------------------------------------------------- 1 | $butterbar-color: #fcf9cf; 2 | 3 | #butterbar { 4 | background: $butterbar-color; 5 | border: solid 1px mix(black, $butterbar-color, 10%); 6 | border-top: none; 7 | 8 | color: mix(black, $butterbar-color, 50%); 9 | font-size: 14px; 10 | padding: 6px 0; 11 | text-align: center; 12 | transition: transform 0.2s ease-out; 13 | 14 | 15 | @media (min-width: 768px) { 16 | border-bottom-left-radius: 3px; 17 | border-bottom-right-radius: 3px; 18 | left: 20vw; 19 | position: fixed; 20 | width: 60vw; 21 | } 22 | 23 | &.hide { 24 | transition-timing-function: ease-in; 25 | transform: translate3d(0, -100%, 0); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.html", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "script/compile-assets" 9 | }, 10 | "author": "", 11 | "license": "Apache-2.0", 12 | "dependencies": { 13 | "async": "^1.5.2", 14 | "babel-core": "^6.4.5", 15 | "babel-loader": "^6.2.2", 16 | "babel-preset-es2015": "^6.5.0", 17 | "babel-preset-react": "^6.5.0", 18 | "chokidar": "^1.4.2", 19 | "cssmin": "^0.4.3", 20 | "node-sass": "4.12.0", 21 | "react": "^0.14.7", 22 | "react-dom": "^0.14.7", 23 | "webpack": "^1.12.13" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /script/start-nginx-proxy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | if [[ $(docker inspect -f {{.ID}} ttt_nginx_proxy_1 2> /dev/null) != "" ]]; then 5 | docker rm -f ttt_nginx_proxy_1 6 | fi 7 | 8 | docker run \ 9 | -d \ 10 | --name ttt_nginx_proxy_1 \ 11 | -p 443:443 \ 12 | -p 80:80 \ 13 | --env affinity:container==ttt_frontend_data \ 14 | --env affinity:container==ttt_nginx_config_data \ 15 | --env affinity:container==ttt_htpasswd_data \ 16 | --env affinity:container==ttt_letsencrypt_data \ 17 | --net tictactoe \ 18 | --restart always \ 19 | --volumes-from ttt_frontend_data \ 20 | --volumes-from ttt_nginx_config_data \ 21 | --volumes-from ttt_htpasswd_data \ 22 | --volumes-from ttt_letsencrypt_data \ 23 | ttt_nginx_proxy 24 | -------------------------------------------------------------------------------- /frontend/src/src/js/socket.js: -------------------------------------------------------------------------------- 1 | var wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; 2 | 3 | var ws = new WebSocket(wsProtocol + window.location.hostname + '/live/'); 4 | // var ws = new WebSocket('wss://tictac.io/live/'); 5 | 6 | ws.addEventListener('open', function(e) { 7 | requestAnimationFrame(function() { 8 | document.getElementById('butterbar').classList.add('hide'); 9 | }); 10 | }); 11 | 12 | ws.addEventListener('close', function(e) { 13 | requestAnimationFrame(function() { 14 | document.getElementById('butterbar').classList.remove('hide'); 15 | }); 16 | }); 17 | 18 | module.exports = ws; 19 | 20 | setInterval(function() { 21 | ws.send(JSON.stringify({action: 'ping'})); 22 | }, 30000); 23 | -------------------------------------------------------------------------------- /frontend/src/src/sass/_footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | @include clearfix(); 3 | color: mix(white, $text-color, 50%); 4 | font-size: 12px; 5 | margin-top: 60px; 6 | padding: 12px 32px; 7 | 8 | .credits { 9 | text-align: center; 10 | 11 | @media (min-width: 768px) { 12 | float: left; 13 | line-height: 80px; 14 | text-align: left; 15 | width: 50%; 16 | } 17 | } 18 | 19 | .powered-by { 20 | margin-top: 20px; 21 | text-align: center; 22 | 23 | img { 24 | height: 80px; 25 | width: auto; 26 | } 27 | 28 | @media (min-width: 768px) { 29 | float: left; 30 | margin-top: 0; 31 | text-align: right; 32 | width: 50%; 33 | } 34 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tic-tac-toe", 3 | "version": "1.0.0", 4 | "description": "This is a simple Tic-Tac-Toe game implemented with HTML5 canvas. You play against yourself.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/ktbartholomew/tic-tac-toe.git" 12 | }, 13 | "author": "", 14 | "license": "Apache-2.0", 15 | "bugs": { 16 | "url": "https://github.com/ktbartholomew/tic-tac-toe/issues" 17 | }, 18 | "homepage": "https://github.com/ktbartholomew/tic-tac-toe#readme", 19 | "dependencies": { 20 | "commander": "^2.9.0", 21 | "nunjucks": "^2.3.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /manifests/02_redis.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | namespace: tic-tac-toe 5 | name: redis 6 | spec: 7 | selector: 8 | app.kubernetes.io/component: redis 9 | ports: 10 | - port: 6379 11 | name: redis 12 | --- 13 | apiVersion: apps/v1 14 | kind: StatefulSet 15 | metadata: 16 | namespace: tic-tac-toe 17 | name: redis 18 | spec: 19 | selector: 20 | matchLabels: 21 | app.kubernetes.io/component: redis 22 | serviceName: "redis" 23 | replicas: 1 24 | template: 25 | metadata: 26 | labels: 27 | app.kubernetes.io/component: redis 28 | spec: 29 | containers: 30 | - name: redis 31 | image: redis:5.0.4 32 | ports: 33 | - containerPort: 6379 34 | name: redis 35 | -------------------------------------------------------------------------------- /manifests/04_frontend.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | namespace: tic-tac-toe 5 | name: frontend 6 | spec: 7 | selector: 8 | app.kubernetes.io/component: frontend 9 | ports: 10 | - port: 80 11 | name: http 12 | --- 13 | apiVersion: apps/v1 14 | kind: Deployment 15 | metadata: 16 | namespace: tic-tac-toe 17 | name: frontend 18 | spec: 19 | selector: 20 | matchLabels: 21 | app.kubernetes.io/component: frontend 22 | replicas: 3 23 | template: 24 | metadata: 25 | labels: 26 | app.kubernetes.io/component: frontend 27 | spec: 28 | containers: 29 | - name: frontend 30 | image: quay.io/ktbartholomew/tic-tac-toe-frontend:latest 31 | ports: 32 | - containerPort: 80 33 | name: http 34 | -------------------------------------------------------------------------------- /frontend/script/lib/do-sass.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var sass = require('node-sass'); 4 | var cssmin = require('cssmin'); 5 | var rootDir = require('./root-dir'); 6 | 7 | module.exports = function (callback) { 8 | callback = callback || function () {}; 9 | 10 | sass.render({ 11 | file: path.resolve(rootDir, 'src/src/sass/main.scss'), 12 | outputStyle: 'expanded' 13 | }, function (err, result) { 14 | if (err) { 15 | return callback(err); 16 | } 17 | 18 | // Minify the compiled scss. 19 | var minified = cssmin(result.css.toString()); 20 | 21 | var outputFile = path.resolve(rootDir, 'src/dist/css/main.css'); 22 | fs.writeFile(outputFile, minified, function (err, result) { 23 | console.log('[doSass] Wrote ' + outputFile); 24 | return callback(err, result); 25 | }); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/script/lib/webpack-config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var rootDir = require('./root-dir'); 4 | 5 | module.exports = { 6 | context: rootDir, 7 | entry: path.resolve(rootDir, 'src/src/js/app.js'), 8 | output: { 9 | path: path.resolve(rootDir, 'src/dist/js'), 10 | publicPath: '/dist/js', 11 | filename: 'app.js' 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.jsx?$/, 17 | loader: 'babel-loader', 18 | exclude: /node_modules/, 19 | query: { 20 | presets: ['es2015', 'react'] 21 | } 22 | } 23 | ], 24 | noParse: [] 25 | }, 26 | plugins: [] 27 | }; 28 | 29 | if (process.env.NODE_ENV === 'production') { 30 | module.exports.plugins.push( 31 | new webpack.optimize.UglifyJsPlugin({ 32 | compress: { 33 | warnings: false 34 | } 35 | }) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /manifests/03_app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | namespace: tic-tac-toe 5 | name: app 6 | spec: 7 | selector: 8 | app.kubernetes.io/component: app 9 | ports: 10 | - port: 8080 11 | name: http 12 | --- 13 | apiVersion: apps/v1 14 | kind: Deployment 15 | metadata: 16 | namespace: tic-tac-toe 17 | name: app 18 | spec: 19 | selector: 20 | matchLabels: 21 | app.kubernetes.io/component: app 22 | replicas: 3 23 | template: 24 | metadata: 25 | labels: 26 | app.kubernetes.io/component: app 27 | spec: 28 | containers: 29 | - name: app 30 | image: quay.io/ktbartholomew/tic-tac-toe-app:latest 31 | env: 32 | - name: RETHINKDB_HOST 33 | value: rethinkdb.tic-tac-toe.svc.cluster.local 34 | - name: REDIS_HOST 35 | value: redis.tic-tac-toe.svc.cluster.local 36 | ports: 37 | - containerPort: 8080 38 | name: http 39 | -------------------------------------------------------------------------------- /app/lib/storage/stats.js: -------------------------------------------------------------------------------- 1 | var r = require('rethinkdb'); 2 | var bootstrap = require('./bootstrap'); 3 | 4 | var StatStorage = { 5 | increment: function (stat) { 6 | return bootstrap.getConnection() 7 | .then(function (rConn) { 8 | return r.table('stats').get(stat) 9 | .update({ 10 | value: r.row('value').add(1) 11 | }) 12 | .run(rConn); 13 | }); 14 | }, 15 | subscribe: function (callback) { 16 | return bootstrap.getConnection() 17 | .then(function (rConn) { 18 | return r.table('stats').changes().run(rConn, function (err, cursor) { 19 | cursor.each(callback); 20 | }); 21 | }); 22 | }, 23 | getStats: function () { 24 | return bootstrap.getConnection() 25 | .then(function (rConn) { 26 | return r.table('stats').run(rConn); 27 | }) 28 | .then(function (cursor) { 29 | return cursor.toArray(); 30 | }); 31 | } 32 | }; 33 | 34 | module.exports = StatStorage; 35 | -------------------------------------------------------------------------------- /frontend/src/src/js/stats/feed.js: -------------------------------------------------------------------------------- 1 | var socket = require('../socket'); 2 | 3 | var subscribers = []; 4 | var data = {}; 5 | 6 | var handleMessage = function (e) { 7 | var message; 8 | try { 9 | message = JSON.parse(e.data); 10 | } catch (error) { 11 | console.error('Received malformed JSON from server: ' + e.data); 12 | console.error(error.stack); 13 | } 14 | 15 | if (typeof handlers[message.action] === 'function') { 16 | handlers[message.action](message.data); 17 | } 18 | }; 19 | 20 | var handlers = { 21 | statsUpdate: function (stats) { 22 | stats.forEach(function (stat) { 23 | data[stat.id] = stat.value; 24 | }); 25 | 26 | subscribers.forEach(function (subscriber) { 27 | subscriber.call(null, data); 28 | }); 29 | } 30 | }; 31 | 32 | socket.addEventListener('message', handleMessage); 33 | 34 | module.exports = { 35 | subscribe: function (callback) { 36 | subscribers.push(callback); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/src/js/stats/table.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Table = React.createClass({ 4 | getInitialState: function () { 5 | return { 6 | data: {} 7 | }; 8 | }, 9 | componentDidMount: function () { 10 | var self = this; 11 | this.props.feed.subscribe(function (update) { 12 | self.setState({data: update}); 13 | }); 14 | }, 15 | render: function () { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
Total Moves{this.state.data.totalMoves}
Total Games{this.state.data.totalGames}
Abandoned Games{this.state.data.abandonedGames}
Tied Games{this.state.data.tiedGames}
Games won by X{this.state.data.wonByX}
Games won by O{this.state.data.wonByO}
45 | ); 46 | } 47 | }); 48 | 49 | module.exports = Table; 50 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ueo pipefail 4 | 5 | root=$(cd $(dirname ${0})/..; pwd) 6 | 7 | echo "Creating overlay network" 8 | ${root}/script/create-network 9 | echo "Creating data volume container for RethinkDB" 10 | ${root}/script/create-db-data 11 | echo "Creating data volume container for Redis" 12 | ${root}/script/create-redis-data 13 | echo "Creating data volume container for NGINX config files" 14 | ${root}/script/create-nginx-config-data 15 | echo "Creating data volume container for Let's Encrypt" 16 | ${root}/script/create-letsencrypt-data 17 | echo "Creating data volume container for NGINX htpasswd file" 18 | ${root}/script/create-htpasswd-data 19 | echo "Creating data volume container for front-end files" 20 | ${root}/script/create-frontend-data 21 | echo "Starting RethinkDB container" 22 | ${root}/script/start-db 23 | echo "Updating DB Schema" 24 | ${root}/script/update-db-schema 25 | echo "Starting Redis container" 26 | ${root}/script/start-redis 27 | echo "Compiling front-end assets and copying to data volume container" 28 | ${root}/script/update-frontend 29 | echo "Building image for Node.js app" 30 | ${root}/script/build-app 31 | echo "Starting 2 \"blue\" Node.js containers" 32 | ${root}/script/start-app 2 blue 33 | echo "Updating NGINX config with IPs of Node.js containers" 34 | ${root}/script/update-nginx blue 35 | echo "Building image for NGINX proxy" 36 | ${root}/script/build-nginx-proxy 37 | echo "Starting NGINX proxy container" 38 | ${root}/script/start-nginx-proxy 39 | -------------------------------------------------------------------------------- /frontend/src/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 17 | 18 | -------------------------------------------------------------------------------- /manifests/01_rethinkdb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | namespace: tic-tac-toe 5 | name: rethinkdb 6 | spec: 7 | selector: 8 | app.kubernetes.io/component: rethinkdb 9 | ports: 10 | - port: 28015 11 | name: client 12 | - port: 29015 13 | name: peer 14 | - port: 8080 15 | name: web 16 | --- 17 | apiVersion: apps/v1 18 | kind: StatefulSet 19 | metadata: 20 | namespace: tic-tac-toe 21 | name: rethinkdb 22 | spec: 23 | selector: 24 | matchLabels: 25 | app.kubernetes.io/component: rethinkdb 26 | serviceName: "rethinkdb" 27 | replicas: 1 28 | template: 29 | metadata: 30 | labels: 31 | app.kubernetes.io/component: rethinkdb 32 | spec: 33 | containers: 34 | - name: rethinkdb 35 | image: rethinkdb:2.3.6 36 | ports: 37 | - containerPort: 28015 38 | name: client 39 | - containerPort: 29015 40 | name: cluster 41 | - containerPort: 8080 42 | name: web 43 | readinessProbe: 44 | tcpSocket: 45 | port: client 46 | initialDelaySeconds: 5 47 | periodSeconds: 10 48 | livenessProbe: 49 | tcpSocket: 50 | port: client 51 | initialDelaySeconds: 15 52 | periodSeconds: 20 53 | --- 54 | apiVersion: batch/v1 55 | kind: Job 56 | metadata: 57 | namespace: tic-tac-toe 58 | name: db-schema 59 | spec: 60 | template: 61 | spec: 62 | containers: 63 | - name: db-schema 64 | image: quay.io/ktbartholomew/tic-tac-toe-db-schema 65 | env: 66 | - name: DB_HOST 67 | value: rethinkdb.tic-tac-toe.svc.cluster.local 68 | restartPolicy: Never 69 | backoffLimit: 6 70 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Tic Tac Toe 12 | 16 | 17 | 18 | 19 | 20 |
21 | Connecting to the game server... 22 |
23 |
24 |

Tic Tac Toe

25 |
26 |
27 |
28 |
29 |
30 | Status: Waiting for opponent to join 32 |
33 |
34 | Your team: 35 |
36 |
37 |
38 |
39 |
40 |

Global Stats

41 |
42 |
43 |
44 | 52 | 53 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /frontend/src/src/sass/_basics.scss: -------------------------------------------------------------------------------- 1 | body, html { 2 | background: #fafafa; 3 | color: $text-color; 4 | font-family: Helvetica, Arial, Sans-serif; 5 | margin: 0; 6 | padding: 0; 7 | 8 | * { 9 | box-sizing: border-box; 10 | -moz-box-sizing: border-box; 11 | } 12 | } 13 | 14 | a { 15 | color: $blue; 16 | text-decoration: none; 17 | 18 | &:hover { 19 | text-decoration: underline; 20 | } 21 | } 22 | 23 | .header { 24 | @include clearfix(); 25 | height: 48px; 26 | padding: 12px 32px; 27 | 28 | h1 { 29 | float: left; 30 | font-weight: normal; 31 | font-size: 24px; 32 | margin: 0; 33 | } 34 | } 35 | 36 | .game-area { 37 | @include clearfix(); 38 | padding: 0 12px; 39 | text-align: center; 40 | } 41 | 42 | .game-status { 43 | padding: 20px; 44 | text-align: center; 45 | 46 | #game-team img { 47 | display: inline-block; 48 | height: 1em; 49 | margin-left: 0.5em; 50 | vertical-align: top; 51 | width: auto; 52 | } 53 | } 54 | 55 | .current-game { 56 | display: inline-block; 57 | vertical-align: top; 58 | width: auto; 59 | } 60 | 61 | .stats { 62 | display: inline-block; 63 | width: 100%; 64 | 65 | @media (min-width: 768px) { 66 | min-width: 25vw; 67 | padding-left: 40px; 68 | width: auto; 69 | } 70 | 71 | h4 { 72 | margin: 0; 73 | line-height: 76px; 74 | } 75 | 76 | table { 77 | background: white; 78 | box-shadow: 0px 1px 2px rgba(black, 0.14); 79 | border-spacing: 0px; 80 | border-collapse: collapse; 81 | width: 100%; 82 | 83 | tr { 84 | & + tr { 85 | th, td { 86 | border-top: solid 1px #dadada; 87 | } 88 | } 89 | 90 | &:nth-child(2n) { 91 | th, td { 92 | background: mix($blue, white, 8%); 93 | } 94 | } 95 | 96 | th, td { 97 | padding: 0.75em; 98 | margin: 0; 99 | } 100 | 101 | th { 102 | text-align: left; 103 | } 104 | 105 | td.number { 106 | text-align: right; 107 | } 108 | } 109 | } 110 | } 111 | 112 | #game-container { 113 | background: white; 114 | box-shadow: 0px 1px 2px rgba(black, 0.14); 115 | margin: 0 auto; 116 | max-width: 448px; 117 | padding: 32px; 118 | text-align: center; 119 | width: 100%; 120 | 121 | canvas { 122 | cursor: pointer; 123 | display: block; 124 | margin: 0 auto; 125 | max-width: 384px; 126 | transform: translateZ(0); 127 | width: 100%; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /script/update-nginx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @desc Get the published ports of all running app containers with a given 5 | * deployment color (blue/green) and build an NGINX config file with each 6 | * of those containers as a server in an NGINX backend pool. Copy the 7 | * resulting config file to the nginx_proxy container, and have the 8 | * container reload the config file to apply the changes. 9 | * @example update-nginx 10 | * 11 | */ 12 | 13 | var fs = require('fs'); 14 | var path = require('path'); 15 | var child_process = require('child_process'); 16 | var nunjucks = require('nunjucks'); 17 | 18 | var APP_COLOR = process.argv[2] || ''; 19 | 20 | var spawn = function () { 21 | var spawnArgs = arguments; 22 | return new Promise(function (resolve, reject) { 23 | var spawned = child_process.spawn.apply(null, spawnArgs); 24 | spawned.stdout.on('data', function (data) { 25 | return resolve(data); 26 | }); 27 | 28 | spawned.stderr.on('data', function (data) { 29 | return reject(data); 30 | }); 31 | 32 | spawned.on('close', function (code) { 33 | if (code !== 0) { 34 | return reject(); 35 | } 36 | 37 | return resolve(); 38 | }); 39 | }); 40 | } 41 | 42 | // Get the IDs of all the running app containers with our current deployment 43 | // color 44 | spawn('docker', [ 45 | 'ps', 46 | '-f', 47 | 'name=ttt_app_', 48 | '-f', 49 | 'label=color' + ((APP_COLOR) ? '=' + APP_COLOR : ''), 50 | '--format', 51 | '{{.Names}}' 52 | ], {}) 53 | .then(function (data) { 54 | if (!data) { 55 | return []; 56 | } 57 | 58 | var servers = data.toString().trim(); 59 | servers = servers.split('\n'); 60 | 61 | servers.forEach(function (server, index, scope) { 62 | // strip out the {NodeName}/ that Swarm adds here 63 | scope[index] = path.basename(server); 64 | }); 65 | 66 | return servers; 67 | }) 68 | .then(function (serverIPs) { 69 | // We now have an array of server IPs and ports, so let's render those into an NGINX config file 70 | var nginxConfTemplate = path.resolve(__dirname, '../nginx/default.conf.template'); 71 | var nginxConfFile = path.resolve(__dirname, '../nginx/default.conf'); 72 | 73 | var config = nunjucks.render(nginxConfTemplate, { 74 | parseInt: parseInt, 75 | env: process.env, 76 | app_color: APP_COLOR, 77 | app_servers: serverIPs 78 | }); 79 | 80 | return new Promise(function (resolve, reject) { 81 | fs.writeFile(nginxConfFile, config, 'utf-8', function (err, result) { 82 | if (err) { 83 | return reject(err); 84 | } 85 | 86 | return resolve(result); 87 | }); 88 | }); 89 | }) 90 | .then(function (output) { 91 | // Copy our generated config file to the nginx_proxy container 92 | return spawn('docker', [ 93 | 'cp', 94 | path.resolve(__dirname, '../nginx/default.conf'), 95 | 'ttt_nginx_config_data:/etc/nginx/conf.d/default.conf' 96 | ]); 97 | }) 98 | .then(function (result) { 99 | // Send SIGHUP to NGINX so it will reload the new config file 100 | try { 101 | return spawn('docker', [ 102 | 'kill', 103 | '-s', 104 | 'HUP', 105 | 'ttt_nginx_proxy_1' 106 | ]); 107 | } catch (e) { 108 | console.log('[WARN] ttt_nginx_proxy_1 isn\'t running yet'); 109 | return result; 110 | } 111 | }) 112 | .catch(function (error) { 113 | console.log(error.toString()); 114 | }); 115 | -------------------------------------------------------------------------------- /nginx/default.conf.template: -------------------------------------------------------------------------------- 1 | {#- 2 | Location blocks that define the document root and proxy paths for NGINX. 3 | This macro will be either be rendered in the block for *:80 or *:443, 4 | depending on the value of env.NGINX_SSL (0 or 1) 5 | -#} 6 | {%- macro pathStuff() %} 7 | location / { 8 | root /usr/share/nginx/html; 9 | } 10 | 11 | location /live/ { 12 | proxy_pass http://app/; 13 | proxy_http_version 1.1; 14 | proxy_set_header Upgrade $http_upgrade; 15 | proxy_set_header Connection "upgrade"; 16 | } 17 | 18 | {% if env.NGINX_RETHINKDB_PASS -%} 19 | location /rethinkdb/ { 20 | auth_basic rethinkdb; 21 | auth_basic_user_file /etc/nginx/htpasswd/rethinkdb; 22 | proxy_pass http://ttt_db:8080/; 23 | } 24 | {%- endif -%} 25 | {% endmacro -%} 26 | 27 | upstream app { 28 | {#- 29 | Use the ip_hash LB method to stick clients to the same backend host as much 30 | as possible 31 | -#} 32 | ip_hash; 33 | {#- 34 | app_servers is an array of IPs and port numbers for all the app servers in 35 | the given deployment (blue/green/whatever) 36 | -#} 37 | {%- for server in app_servers %} 38 | server {{ server }}:8080; 39 | {%- endfor %} 40 | } 41 | 42 | server { 43 | listen 80; 44 | server_name {{ env.NGINX_DOMAIN }}; 45 | {%- if parseInt(env.NGINX_SSL) %} 46 | return 301 https://$server_name$request_uri; 47 | {%- else -%} 48 | {{ pathStuff() }} 49 | {%- endif %} 50 | } 51 | {% if parseInt(env.NGINX_SSL) %} 52 | server { 53 | listen 443 ssl; 54 | 55 | # certs sent to the client in SERVER HELLO are concatenated in ssl_certificate 56 | ssl_certificate /etc/letsencrypt/live/{{ env.NGINX_DOMAIN }}/fullchain.pem; 57 | ssl_certificate_key /etc/letsencrypt/live/{{ env.NGINX_DOMAIN }}/privkey.pem; 58 | ssl_dhparam /etc/letsencrypt/dhparams.pem; 59 | 60 | ssl_session_timeout 1d; 61 | ssl_session_cache shared:SSL:50m; 62 | ssl_session_tickets off; 63 | 64 | # modern configuration. tweak to your needs. 65 | ssl_protocols TLSv1.1 TLSv1.2; 66 | ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; 67 | ssl_prefer_server_ciphers on; 68 | 69 | # HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months) 70 | # add_header Strict-Transport-Security max-age=15768000; 71 | 72 | # OCSP Stapling --- 73 | # fetch OCSP records from URL in ssl_certificate and cache them 74 | ssl_stapling on; 75 | ssl_stapling_verify on; 76 | 77 | ## verify chain of trust of OCSP response using Root CA and Intermediate certs 78 | ssl_trusted_certificate /etc/letsencrypt/live/{{ env.NGINX_DOMAIN }}/chain.pem; 79 | 80 | resolver 8.8.8.8 8.8.4.4 valid=86400; 81 | 82 | {{ pathStuff() }} 83 | 84 | # Pass the ACME challenge from letsencrypt to a directory within the container 85 | 86 | location /.well-known/acme-challenge { 87 | alias /etc/letsencrypt/webrootauth/.well-known/acme-challenge; 88 | location ~ /.well-known/acme-challenge/(.*) { 89 | add_header Content-Type application/jose+json; 90 | } 91 | } 92 | } 93 | 94 | {% endif %} 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tic Tac Toe 2 | 3 | This is a real-time Tic-Tac-Toe game. The front-end consists of HTML5 Canvas to draw the game board, and WebSockets to pass two-way game information and statistics in real-time. The back-end consists of an NGINX proxy that also serves the static assets, several Node.js WebSocket handlers, a Redis pub/sub queue, and RethinkDB for persistent storage. 4 | 5 | The entire stack runs in a handful of Docker containers, and is super-easy to deploy on a platform like [Carina](https://getcarina.com/). 6 | 7 | [Play the game](https://tictac.io/) 8 | 9 | [Read the blog post](blog/README.md) 10 | 11 | ### Installation 12 | 13 | **Before you start:** This application depends on overlay networks, so you'll need to run this on a Docker host that supports them. Any Carina cluster created after 2016-02-15 has this capability. 14 | 15 | 1. First, [clone the GitHub repo](https://github.com/ktbartholomew/tic-tac-toe). This repo contains all of the application code and Docker scripts needed to start the right containers and run the entire application. 16 | 1. Copy `env.example` to a new file named `env` and set the environment variables with values that are appropriate for your environment. For simplicity, set `NGINX_SSL` to `0` to avoid the extra complication of getting certificates from Let's Encrypt. 17 | 1. Run `script/setup`. This runs a long series of Bash scripts found in `script/` that create data volume containers, build custom images, and start the containers necessary to run the app. 18 | 1. Run `$(docker port ttt_nginx_proxy_1 80)` to get the public IP address of the NGINX proxy container. Visiting this IP address exposes the front-end of the tic-tac-toe game. Visiting `/live/` proxies to the WebSocket handlers, and visiting `/rethinkdb/` proxies to the RethinkDB web interface, if you've assigne a password to `${NGINX_RETHINKDB_PASS}`. 19 | 20 | ### Components 21 | 22 | For the purpose of demonstration, the repo contains application code for several different components that would ideally be contained in separate repositories. 23 | 24 | * `app/` contains a Node.js application that acts a WebSocket server for game clients. This application expects to have access to a RethinkDB server and a Redis server. 25 | * `bot/` contains a very poorly-written WebSocket client that plays games of tic-tac-toe indefinitely. It "thinks" (twiddles its thumbs) for a random amount of time between 0.8 and 1.8 seconds, then picks a random available spot on the game board. 26 | * `cron/` contains a simple Bash script that renews an SSL certificate from Let's Encrypt. When the cron container is running, the script runs and renews the certificate monthly. 27 | * `db-schema/` contains a Node.js script that creates the databases, tables, and indexes the application needs to run. 28 | * `frontend/` contains the SCSS and Javascript for the front-end of the game. It also contains Bash scripts to compile these assets into browser-ready assets. 29 | * `nginx/` contains a [Nunjucks](https://mozilla.github.io/nunjucks/) template for an NGINX configuration file. This template is rendered by `script/update-nginx`, which then copies the final configuration file to the appropriate data container and tells NGINX to reload its config. 30 | 31 | ### Admin scripts 32 | 33 | `script/` contains all the commands needed to set up and run the application's various containers. They all have pretty self-explanatory names. Each of the scripts is fairly idempotent, so they can be run multiple times without changing the overall state of the application too much. 34 | 35 | **N.B:** `script/update-frontend` and `script/update-nginx` are Node.js command-line scripts, so you'll need a working Node environment on your local machine to run them. Sorry about that. 36 | -------------------------------------------------------------------------------- /app/lib/storage/games.js: -------------------------------------------------------------------------------- 1 | var r = require('rethinkdb'); 2 | 3 | var bootstrap = require('./bootstrap'); 4 | var StatStorage = require('./stats'); 5 | var Game = require('../game'); 6 | 7 | module.exports = { 8 | add: function (game) { 9 | StatStorage.increment('totalGames'); 10 | 11 | return bootstrap.getConnection() 12 | .then(function (rConn) { 13 | return r.table('games').insert(game, {returnChanges: true}).run(rConn); 14 | }) 15 | .then(function (result) { 16 | return result.changes[0].new_val; 17 | }); 18 | }, 19 | joinOpenGame: function (player) { 20 | // Try to join a game as player "O" 21 | return bootstrap.getConnection() 22 | .then(function (rConn) { 23 | return r.db('tictactoe') 24 | .table('games') 25 | .getAll('waiting', {index: 'status'}) 26 | .limit(1) 27 | .run(rConn); 28 | }) 29 | .then(function (cursor) { 30 | return cursor.toArray(); 31 | }) 32 | .then(function (result) { 33 | var game; 34 | if (result.length === 0) { 35 | // No games were open, so create a new game as player "X". 36 | game = new Game(); 37 | game.addPlayer(player.socketId); 38 | return this.add(game) 39 | .then(function (game) { 40 | console.log('Player %s joined game %s', player.socketId, game.id); 41 | return game; 42 | }); 43 | } 44 | 45 | // Otherwise, result is a game matching {status: 'waiting'} 46 | game = new Game(result[0]); 47 | game.addPlayer(player.socketId); 48 | console.log('Player %s joined game %s', player.socketId, game.id); 49 | 50 | return this.update(game); 51 | }.bind(this)); 52 | }, 53 | getGame: function (id) { 54 | return bootstrap.getConnection() 55 | .then(function (rConn) { 56 | return r.table('games').get(id).run(rConn); 57 | }) 58 | .then(function (game) { 59 | return new Game(game); 60 | }); 61 | }, 62 | getGamesWithPlayer: function (playerId) { 63 | return bootstrap.getConnection() 64 | .then(function (rConn) { 65 | return r.table('games') 66 | .getAll(playerId, {index: 'playerSocketId'}) 67 | .run(rConn); 68 | }); 69 | }, 70 | updateStatus: function (id, status) { 71 | this.getGame(id) 72 | .then(function (game) { 73 | game.updateStatus(status); 74 | 75 | return this.update(game); 76 | }.bind(this)); 77 | }, 78 | removePlayer: function (gameId, playerId) { 79 | this.getGame(gameId) 80 | .then(function (game) { 81 | game.removePlayer(playerId); 82 | 83 | if (game.status === 'abandoned') { 84 | StatStorage.increment('abandonedGames'); 85 | } 86 | 87 | return this.update(game); 88 | }.bind(this)); 89 | }, 90 | update: function (game) { 91 | return bootstrap.getConnection() 92 | .then(function (rConn) { 93 | return r.table('games') 94 | .get(game.id) 95 | .update(game, {returnChanges: true}) 96 | .run(rConn); 97 | }) 98 | .then(function (result) { 99 | if (result.changes[0]) { 100 | return result.changes[0].new_val; 101 | } 102 | }); 103 | }, 104 | addMove: function (game) { 105 | StatStorage.increment('totalMoves'); 106 | 107 | if (game.winner === 'x') { 108 | StatStorage.increment('wonByX'); 109 | } 110 | 111 | if (game.winner === 'o') { 112 | StatStorage.increment('wonByO'); 113 | } 114 | 115 | if (game.status === 'finished-draw') { 116 | StatStorage.increment('tiedGames'); 117 | } 118 | return bootstrap.getConnection() 119 | .then(function (rConn) { 120 | return r.table('games') 121 | .get(game.id) 122 | .update({ 123 | status: game.status, 124 | next: game.next, 125 | grid: game.grid, 126 | totalMoves: game.totalMoves, 127 | winner: game.winner 128 | }) 129 | .run(rConn); 130 | }); 131 | } 132 | }; 133 | -------------------------------------------------------------------------------- /app/lib/ws-connection.js: -------------------------------------------------------------------------------- 1 | var Sockets = require('./sockets'); 2 | var GameStorage = require('./storage/games'); 3 | var StatStorage = require('./storage/stats'); 4 | var Redis = require('./storage/redis'); 5 | 6 | /** 7 | * Arbiter for all websocket connections in a game, which corresponds to a 8 | * single player's actions. 9 | * 10 | */ 11 | var WSConnection = function (options) { 12 | options = options || {}; 13 | 14 | Object.defineProperty(this, 'id', {enumerable: true, value: options.id}); 15 | Object.defineProperty(this, 'socket', {value: options.socket}); 16 | Object.defineProperty(this, 'redisClient', {value: Redis.getClient()}); 17 | 18 | addEventListeners.bind(this)(); 19 | this.getStats(); 20 | }; 21 | 22 | WSConnection.prototype.sendMessage = function (message) { 23 | try { 24 | this.socket.send(JSON.stringify(message)); 25 | } catch (e) { 26 | console.log(e.stack); 27 | } 28 | }; 29 | 30 | WSConnection.prototype.getStats = function () { 31 | StatStorage.getStats() 32 | .then(function (stats) { 33 | this.sendMessage({ 34 | action: 'statsUpdate', 35 | data: stats 36 | }); 37 | }.bind(this)); 38 | }; 39 | 40 | WSConnection.prototype.destroy = function () { 41 | this.redisClient.unsubscribe(); 42 | delete Sockets[this.id]; 43 | }; 44 | 45 | var addEventListeners = function () { 46 | this.redisClient.on('message', function (channel, message) { 47 | try { 48 | this.socket.send(message); 49 | } catch (e) { 50 | console.log('Unable to relay message received from Redis channel %s', channel); 51 | } 52 | }.bind(this)); 53 | this.redisClient.subscribe('sockets:' + this.id); 54 | 55 | this.socket.on('message', function (message) { 56 | var data; 57 | try { 58 | data = JSON.parse(message); 59 | } catch (e) { 60 | return console.log('Message not valid JSON: %s', message); 61 | } 62 | 63 | if (typeof handlers[data.action] !== 'function') { 64 | return; 65 | } 66 | 67 | handlers[data.action].bind(this)(data.data, function (err, result) { 68 | if (result.send) { 69 | this.socket.send(JSON.stringify(result.send)); 70 | } 71 | }.bind(this)); 72 | }.bind(this)); 73 | 74 | this.socket.on('close', function () { 75 | console.log('Socket %s is closing', this.id); 76 | GameStorage.getGamesWithPlayer(this.id) 77 | .then(function (cursor) { 78 | cursor.each(function (err, game) { 79 | GameStorage.removePlayer(game.id, this.id); 80 | }.bind(this)); 81 | }.bind(this)); 82 | 83 | this.destroy(); 84 | }.bind(this)); 85 | }; 86 | 87 | var handlers = { 88 | joinGame: function (data, callback) { 89 | var socketId = this.id; 90 | var player; 91 | var team; 92 | 93 | // Try to join an open game. Returns null if there are no games to join. 94 | 95 | return GameStorage.joinOpenGame({ 96 | socketId: socketId 97 | }) 98 | .then(function (result) { 99 | var findMyTeam = function (socketId) { 100 | var myTeam; 101 | 102 | result.players.forEach(function (player) { 103 | if (player.socketId === socketId) { 104 | myTeam = player.team; 105 | } 106 | }); 107 | 108 | return myTeam; 109 | }; 110 | 111 | callback(null, { 112 | send: { 113 | action: 'joinGame', 114 | data: { 115 | gameId: result.id, 116 | team: findMyTeam(socketId) 117 | } 118 | } 119 | }); 120 | }); 121 | }, 122 | fillSquare: function (data, callback) { 123 | GameStorage.getGame(data.gameId) 124 | .then(function (game) { 125 | var fillStatus = game.fillSquare(data); 126 | 127 | if (fillStatus !== false) { 128 | return GameStorage.addMove(game); 129 | } 130 | }); 131 | }, 132 | ping: function (data, callback) { 133 | callback(null, { 134 | send: { 135 | action: 'pong' 136 | } 137 | }); 138 | } 139 | }; 140 | 141 | module.exports = WSConnection; 142 | -------------------------------------------------------------------------------- /frontend/src/src/js/game.js: -------------------------------------------------------------------------------- 1 | var GameRenderer = require('./renderer'); 2 | 3 | var WAITING = 'waiting'; 4 | var IN_PROGRESS = 'in-progress'; 5 | var ABANDONED = 'abandoned'; 6 | var FINISHED = 'finished'; 7 | var FINISHED_DRAW = 'finished-draw'; 8 | 9 | var Game = function (options) { 10 | this.socket = options.socket; 11 | 12 | this.resetGame(); 13 | 14 | this.renderer = new GameRenderer({ 15 | game: this, 16 | container: options.container 17 | }); 18 | 19 | this.renderer.render(); 20 | 21 | this.socket.addEventListener('open', this.join.bind(this)); 22 | this.socket.addEventListener('message', handleMessage.bind(this)); 23 | }; 24 | 25 | Game.prototype.resetGame = function () { 26 | this.id = null; 27 | this.status = WAITING; 28 | this.winner = null; 29 | this.myTeam = null; 30 | this.next = 'x'; 31 | this.grid = [ 32 | [ 33 | null, 34 | null, 35 | null 36 | ], 37 | [ 38 | null, 39 | null, 40 | null 41 | ], 42 | [ 43 | null, 44 | null, 45 | null 46 | ] 47 | ]; 48 | }; 49 | 50 | Game.prototype.fillSquare = function (options) { 51 | this.socket.send(JSON.stringify({ 52 | action: 'fillSquare', 53 | data: { 54 | gameId: this.id, 55 | team: this.myTeam, 56 | coords: { 57 | x: options.x, 58 | y: options.y, 59 | } 60 | } 61 | })); 62 | }; 63 | 64 | Game.prototype.join = function () { 65 | this.socket.send(JSON.stringify({ 66 | action: 'joinGame', 67 | gameId: null 68 | })); 69 | }; 70 | 71 | Game.prototype.leave = function () { 72 | this.socket.send(JSON.stringify({ 73 | action: 'leaveGame', 74 | data: { 75 | team: this.myTeam 76 | } 77 | })); 78 | }; 79 | 80 | Game.prototype.gameOver = function () { 81 | setTimeout(function () { 82 | this.leave(); 83 | this.resetGame(); 84 | this.join(); 85 | }.bind(this), Math.floor(Math.random() * 2500) + 2000); 86 | }; 87 | 88 | var handleMessage = function (e) { 89 | var message; 90 | try { 91 | message = JSON.parse(e.data); 92 | } catch (error) { 93 | console.error('Received malformed JSON from server: ' + e.data); 94 | console.error(error.stack); 95 | } 96 | 97 | if (typeof handlers[message.action] === 'function') { 98 | handlers[message.action].bind(this)(message); 99 | } 100 | }; 101 | 102 | var handlers = { 103 | joinGame: function (message) { 104 | this.id = message.data.gameId; 105 | this.myTeam = message.data.team; 106 | var teamImage = new Image(); 107 | 108 | if (this.myTeam === 'x') { 109 | teamImage.src = '/x.png'; 110 | } else { 111 | teamImage.src = '/circle.png'; 112 | } 113 | 114 | handlers.updateGameStatus.bind(this)({ 115 | data: { 116 | status: (this.myTeam === 'x') ? WAITING : IN_PROGRESS 117 | } 118 | }); 119 | 120 | this.renderer.render(); 121 | requestAnimationFrame(function () { 122 | document.getElementById('game-team').innerHTML = ''; 123 | document.getElementById('game-team').appendChild(teamImage); 124 | }.bind(this)); 125 | }, 126 | updateGameStatus: function (message) { 127 | this.status = message.data.status; 128 | var statusString; 129 | 130 | switch(this.status) { 131 | case IN_PROGRESS: 132 | statusString = 'In progress'; 133 | break; 134 | case WAITING: 135 | statusString = 'Waiting for opponent to join'; 136 | break; 137 | case ABANDONED: 138 | statusString = 'Abandoned (a player left the game)'; 139 | this.gameOver(); 140 | break; 141 | case FINISHED: 142 | statusString = 'Finished'; 143 | this.gameOver(); 144 | break; 145 | case FINISHED_DRAW: 146 | statusString = 'Draw'; 147 | this.gameOver(); 148 | break; 149 | } 150 | 151 | this.renderer.updateGrid(); 152 | requestAnimationFrame(function () { 153 | document.getElementById('game-status').textContent = statusString; 154 | }.bind(this)); 155 | }, 156 | updateGrid: function (message) { 157 | this.grid = message.data.grid; 158 | this.renderer.updateGrid(); 159 | }, 160 | updateWinner: function (message) { 161 | this.winner = message.data.winner; 162 | this.renderer.updateGrid(); 163 | } 164 | }; 165 | 166 | module.exports = Game; 167 | -------------------------------------------------------------------------------- /frontend/src/src/js/renderer.js: -------------------------------------------------------------------------------- 1 | var red = '#CC523E'; 2 | var blue = '#698DCC'; 3 | 4 | var GridImage = new Image(); 5 | GridImage.src = '/grid.png'; 6 | 7 | var TTX = new Image(); 8 | TTX.src = '/x.png'; 9 | 10 | var TTCircle = new Image(); 11 | TTCircle.src = '/circle.png'; 12 | 13 | var FilledSquare = function (options) { 14 | this.created = new Date(); 15 | this.sprite = options.sprite; 16 | }; 17 | 18 | 19 | var GameRenderer = function (options) { 20 | this.game = options.game; 21 | this.grid = [[null,null,null],[null,null,null],[null,null,null]]; 22 | this.animationInProgress = false; 23 | 24 | this.canvas = document.createElement('canvas'); 25 | this.canvas.width = 768; 26 | this.canvas.height = 768; 27 | 28 | options.container.innerHTML = ''; 29 | options.container.appendChild(this.canvas); 30 | 31 | this.canvas.addEventListener('click', clickOrTapHandler.bind(this)); 32 | this.canvas.addEventListener('touchend', clickOrTapHandler.bind(this)); 33 | }; 34 | 35 | GameRenderer.prototype.updateGrid = function () { 36 | for(var i = 0; i < this.game.grid.length; i++) { 37 | for(var j = 0; j < this.game.grid[i].length; j++) { 38 | // If the grid has changed to null, we change to null. Pretty simple. 39 | if (this.game.grid[i][j] === null) { 40 | this.grid[i][j] = null; 41 | continue; 42 | } 43 | 44 | // If our square was null, fill it with whatever the new grid has. 45 | if (this.grid[i][j] === null) { 46 | this.grid[i][j] = new FilledSquare({ 47 | sprite: (this.game.grid[i][j] === 'x') ? TTX : TTCircle 48 | }); 49 | continue; 50 | } 51 | 52 | // Don't recreate the filledSquare if the sprite is unchanged 53 | if (this.game.grid[i][j] === 'x' && this.grid[i][j].sprite != TTX) { 54 | this.grid[i][j] = new FilledSquare({ 55 | sprite: TTX 56 | }); 57 | continue; 58 | } 59 | 60 | if (this.game.grid[i][j] === 'o' && this.grid[i][j].sprite != TTCircle) { 61 | this.grid[i][j] = new FilledSquare({ 62 | sprite: TTCircle 63 | }); 64 | continue; 65 | } 66 | } 67 | } 68 | 69 | this.render(); 70 | }; 71 | 72 | GameRenderer.prototype.render = function () { 73 | requestAnimationFrame(doRender.bind(this)); 74 | }; 75 | 76 | var doRender = function () { 77 | var ctx = this.canvas.getContext('2d'); 78 | ctx.save(); 79 | ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); 80 | 81 | ctx.drawImage(GridImage, 0, 0, 768, 768); 82 | 83 | var animationInProgress = false; 84 | 85 | // Loop through and render the squares on the board 86 | for(var i = 0; i < this.grid.length; i++) { 87 | for(var j = 0; j < this.grid[i].length; j++) { 88 | if (this.grid[i][j]) { 89 | var age = new Date() - this.grid[i][j].created; 90 | 91 | if (age < 150) { 92 | animationInProgress = true; 93 | } 94 | 95 | var agingPercent = Math.min(age / 150, 1); 96 | agingPercent = agingPercent * agingPercent * agingPercent; 97 | 98 | var squareCoords = toCanvasGrid({x: i, y: j}); 99 | 100 | squareCoords.x = squareCoords.x + 32 + (96 - 96 * agingPercent); 101 | squareCoords.y = squareCoords.y + 32 + (96 - 96 * agingPercent); 102 | 103 | ctx.drawImage(this.grid[i][j].sprite, squareCoords.x, squareCoords.y, 192 * agingPercent, 192 * agingPercent); 104 | } 105 | } 106 | } 107 | 108 | // If there's a winner, show who won 109 | if (this.game.winner) { 110 | var winnerName; 111 | if(this.game.winner == 'x') { 112 | winnerName = 'Red'; 113 | ctx.fillStyle = red; 114 | } else { 115 | winnerName = 'Blue'; 116 | ctx.fillStyle = blue; 117 | } 118 | 119 | ctx.fillRect(0, 352, 768, 64); 120 | 121 | ctx.font = '48px sans-serif'; 122 | ctx.fillStyle = 'white'; 123 | ctx.textBaseline = 'middle'; 124 | ctx.shadowColor = 'rgba(0, 0, 0, 0.85)'; 125 | ctx.shadowBlur = 4; 126 | ctx.shadowOffsetY = 0; 127 | ctx.textAlign = 'center'; 128 | ctx.fillText(winnerName + ' wins!', 384, 384); 129 | } 130 | 131 | ctx.restore(); 132 | 133 | if(animationInProgress) { 134 | this.render(); 135 | } 136 | 137 | }; 138 | 139 | // convert a pixel coordinate (the size of the canvas) to a game coordinate 140 | // (a 0-indexed tic-tac-toe grid) 141 | var toTTGrid = function (coords) { 142 | var grid = { 143 | x: 0, 144 | y: 0 145 | }; 146 | 147 | grid.x = parseInt(coords.x/256); 148 | grid.y = parseInt(coords.y/256); 149 | 150 | return grid; 151 | }; 152 | 153 | var toCanvasGrid = function (coords) { 154 | var grid = { 155 | x: 0, 156 | y: 0 157 | }; 158 | 159 | grid.x = coords.x * 256; 160 | grid.y = coords.y * 256; 161 | 162 | return grid; 163 | }; 164 | 165 | var clickOrTapHandler = function (e) { 166 | // Prevent taps from turning into clicks 167 | e.preventDefault(); 168 | 169 | var eventPos = { 170 | x: e.clientX || e.changedTouches[0].clientX, 171 | y: e.clientY || e.changedTouches[0].clientY 172 | }; 173 | 174 | var canvasPos = { 175 | x: 0, 176 | y: 0 177 | }; 178 | 179 | canvasPos.x = (eventPos.x - this.canvas.offsetLeft) * this.canvas.width / this.canvas.offsetWidth; 180 | canvasPos.y = (eventPos.y - this.canvas.offsetTop) * this.canvas.height / this.canvas.offsetHeight; 181 | 182 | var ttCoords = toTTGrid(canvasPos); 183 | 184 | this.game.fillSquare(ttCoords); 185 | }; 186 | 187 | module.exports = GameRenderer; 188 | -------------------------------------------------------------------------------- /app/lib/game.js: -------------------------------------------------------------------------------- 1 | var Sockets = require('./sockets'); 2 | 3 | // Enums for game statuses 4 | var WAITING = 'waiting'; 5 | var IN_PROGRESS = 'in-progress'; 6 | var ABANDONED = 'abandoned'; 7 | var FINISHED = 'finished'; 8 | var FINISHED_DRAW = 'finished-draw'; 9 | 10 | // Enums for things with which squares can be filled 11 | var X = 'x'; 12 | var O = 'o'; 13 | var EMPTY = null; 14 | 15 | var Game = function (data) { 16 | data = data || {}; 17 | 18 | if (data.id) { 19 | this.id = data.id; 20 | } 21 | 22 | this.status = data.status || WAITING; 23 | this.next = data.next || X; 24 | this.players = data.players || []; 25 | // Start with an empty 3x3 grid 26 | this.grid = data.grid || [[EMPTY, EMPTY, EMPTY], [EMPTY, EMPTY, EMPTY], [EMPTY, EMPTY, EMPTY]]; 27 | this.totalMoves = data.totalMoves || 0; 28 | this.winner = data.winner || null; 29 | }; 30 | 31 | Game.prototype.addPlayer = function (socketId) { 32 | if (!this.isJoinable()) { 33 | return; 34 | } 35 | 36 | var newPlayer = { 37 | socketId: socketId, 38 | team: (this.players.length === 0) ? X : O 39 | }; 40 | 41 | this.broadcast({ 42 | action: 'playerJoin', 43 | data: 'A player joined the game' 44 | }); 45 | 46 | this.players.push(newPlayer); 47 | 48 | if (this.players.length == 2) { 49 | this.updateStatus(IN_PROGRESS); 50 | } 51 | 52 | Sockets.publish(newPlayer.socketId, { 53 | action: 'updateGrid', 54 | data: { 55 | grid: this.grid 56 | } 57 | }); 58 | 59 | return newPlayer; 60 | }; 61 | 62 | Game.prototype.isJoinable = function () { 63 | // Player 1 (index 0) might disconnect before a friend joins. Don't throw a 64 | // second person into this abandoned game. 65 | if(this.status === ABANDONED) { 66 | return false; 67 | } 68 | 69 | // Otherwise, as long as there aren't already two people in the game, the 70 | // game is "joinable" 71 | return this.players.length < 2; 72 | }; 73 | 74 | Game.prototype.updateStatus = function (status) { 75 | this.status = status; 76 | 77 | this.broadcast({ 78 | action: 'updateGameStatus', 79 | data: { 80 | status: this.status 81 | } 82 | }); 83 | }; 84 | 85 | Game.prototype.fillSquare = function (options) { 86 | // Don't allow moves on abandoned or finished games. 87 | if([ABANDONED, FINISHED, FINISHED_DRAW].indexOf(this.status) !== -1) { 88 | return false; 89 | } 90 | 91 | // make sure the right person is taking their turn 92 | if(options.team !== this.next) { 93 | return false; 94 | } 95 | 96 | // x and y both need values 97 | if(typeof this.grid[options.coords.x] === 'undefined' || typeof this.grid[options.coords.x][options.coords.y] === 'undefined') { 98 | return false; 99 | } 100 | 101 | // Can't fill an already filled square 102 | if (this.grid[options.coords.x][options.coords.y] !== EMPTY) { 103 | return false; 104 | } 105 | 106 | this.grid[options.coords.x][options.coords.y] = this.next; 107 | this.totalMoves++; 108 | 109 | // change the next filler to the opposite this one. 110 | this.next = (this.next === X) ? O : X; 111 | 112 | this.broadcast({ 113 | action: 'updateGrid', 114 | data: { 115 | grid: this.grid 116 | } 117 | }); 118 | 119 | if (this.isDraw()) { 120 | console.log('Game %s ends in a draw', this.id); 121 | return this.updateStatus(FINISHED_DRAW); 122 | } 123 | 124 | var winner = this.getWinner(); 125 | 126 | if (winner) { 127 | console.log('%s wins game %s', winner, this.id); 128 | this.setWinner(winner); 129 | return this.updateStatus(FINISHED); 130 | } 131 | }; 132 | 133 | Game.prototype.removePlayer = function (socketId) { 134 | if(this.status !== FINISHED && this.status !== FINISHED_DRAW) { 135 | this.updateStatus(ABANDONED); 136 | } 137 | 138 | this.players.forEach(function (item, index, array) { 139 | if (socketId === item.socketId) { 140 | array.splice(index, 1); 141 | } 142 | }.bind(this)); 143 | }; 144 | 145 | Game.prototype.setWinner = function (winner) { 146 | this.winner = winner; 147 | 148 | this.broadcast({ 149 | action: 'updateWinner', 150 | data: { 151 | winner: winner 152 | } 153 | }); 154 | }; 155 | 156 | Game.prototype.getWinner = function () { 157 | var winner = null; 158 | 159 | // There are 8 winning positions (3 horizontal, 3 vertical, 2 diagonal) 160 | winningVectors = [ 161 | [{x: 0, y: 0}, {x: 0, y: 1}, {x: 0, y: 2}], 162 | [{x: 1, y: 0}, {x: 1, y: 1}, {x: 1, y: 2}], 163 | [{x: 2, y: 0}, {x: 2, y: 1}, {x: 2, y: 2}], 164 | [{x: 0, y: 0}, {x: 1, y: 0}, {x: 2, y: 0}], 165 | [{x: 0, y: 1}, {x: 1, y: 1}, {x: 2, y: 1}], 166 | [{x: 0, y: 2}, {x: 1, y: 2}, {x: 2, y: 2}], 167 | [{x: 0, y: 0}, {x: 1, y: 1}, {x: 2, y: 2}], 168 | [{x: 2, y: 0}, {x: 1, y: 1}, {x: 0, y: 2}] 169 | ]; 170 | 171 | winningVectors.forEach(function (vector) { 172 | var fillers = [ 173 | this.grid[vector[0].x][vector[0].y], 174 | this.grid[vector[1].x][vector[1].y], 175 | this.grid[vector[2].x][vector[2].y] 176 | ]; 177 | 178 | if ( 179 | fillers[0] == fillers[1] && 180 | fillers[1] == fillers[2] && 181 | fillers[0] !== null 182 | ) { 183 | winner = fillers[0]; 184 | } 185 | }.bind(this)); 186 | 187 | return winner; 188 | }; 189 | 190 | Game.prototype.isDraw = function () { 191 | return this.getWinner() === null && this.totalMoves === 9; 192 | }; 193 | 194 | Game.prototype.broadcast = function (data) { 195 | this.players.forEach(function (player) { 196 | Sockets.publish(player.socketId, data); 197 | }); 198 | }; 199 | 200 | 201 | module.exports = Game; 202 | -------------------------------------------------------------------------------- /bot/index.js: -------------------------------------------------------------------------------- 1 | var r = require('rethinkdb'); 2 | var WebSocket = require('ws'); 3 | 4 | var ws = new WebSocket('wss://tictac.io/live/'); 5 | var conn = null; 6 | 7 | r.connect({host: process.env.DB_HOST, db: 'tictactoe'}) 8 | .then(function (connection) { 9 | conn = connection; 10 | }); 11 | 12 | var emptyGrid = [[null, null, null],[null, null, null],[null, null, null]]; 13 | 14 | var currentGame = { 15 | id: null, 16 | team: null, 17 | grid: emptyGrid, 18 | winner: null, 19 | frames: [] 20 | }; 21 | 22 | var gridToFrame = function (grid) { 23 | // convert a 2-dimensional grid array to a 9-character string of only [0-2] 24 | // 0 is empty 25 | // 1 is X 26 | // 2 is O 27 | 28 | var frame = ['0','0','0','0','0','0','0','0','0']; 29 | 30 | grid.forEach(function (column, xIndex) { 31 | // Each element of grid is a column. 32 | column.forEach(function (cell, yIndex) { 33 | // Each element of the column is a cell, from top to bottom. 34 | // We can translate the position in the 2d-array to the 1d array with this 35 | // little formula: 36 | var frameIndex = (yIndex * 3) + xIndex; 37 | 38 | switch (cell) { 39 | case null: 40 | frame[frameIndex] = '0'; 41 | break; 42 | case 'x': 43 | frame[frameIndex] = '1'; 44 | break; 45 | case 'o': 46 | frame[frameIndex] = '2'; 47 | break; 48 | } 49 | }); 50 | }); 51 | 52 | return frame.join(''); 53 | }; 54 | 55 | var playToFrame = function (play) { 56 | // we will switch one of the characters in this string to 1 or 2, for X or O. 57 | var frame = ['0','0','0','0','0','0','0','0','0']; 58 | var playIndex = (play.coords.y * 3) + play.coords.x; 59 | frame[playIndex] = (play.team === 'x') ? '1' : '2'; 60 | 61 | // squash the array to a string 62 | return frame.join(''); 63 | }; 64 | 65 | var storeFrames = function (currentGame) { 66 | var winner; 67 | if (currentGame.winner === null) { 68 | winner = '0'; 69 | } else { 70 | winner = (currentGame.winner === 'x') ? '1' : '2'; 71 | } 72 | 73 | currentGame.frames.forEach(function (frame, index, scope) { 74 | scope[index].result = winner; 75 | }); 76 | 77 | return r.table('bot_stats').insert(currentGame.frames).run(conn); 78 | }; 79 | 80 | var send = function (data) { 81 | ws.send(JSON.stringify(data)); 82 | }; 83 | 84 | var waitThenSend = function (data) { 85 | var minWait = 80; 86 | var maxWait = 200; 87 | 88 | return new Promise(function (resolve, reject) { 89 | setTimeout(function () { 90 | send(data); 91 | return resolve(); 92 | }, Math.floor(Math.random() * maxWait) + minWait); 93 | }); 94 | }; 95 | 96 | var move = function () { 97 | 98 | var pickASquare = function () { 99 | var choices = []; 100 | 101 | for (var i = 0; i <= 2; i++) { 102 | for (var j = 0; j <= 2; j++) { 103 | if (currentGame.grid[i][j] === null) { 104 | choices.push({ 105 | x: i, 106 | y: j 107 | }); 108 | } 109 | } 110 | } 111 | 112 | return choices[Math.floor(Math.random() * (choices.length - 1))]; 113 | }; 114 | 115 | var square = pickASquare(); 116 | 117 | if (!square) { 118 | return {}; 119 | } 120 | 121 | // Record the move in our list of frames, which will be submitted to the DB 122 | // when we know who wins. 123 | currentGame.frames.push({ 124 | state: gridToFrame(currentGame.grid), 125 | action: playToFrame({team: currentGame.team, coords: square}), 126 | result: null 127 | }); 128 | 129 | console.log('[%s] Playing %j', new Date(), square); 130 | 131 | return { 132 | action: 'fillSquare', 133 | data: { 134 | gameId: currentGame.id, 135 | team: currentGame.team, 136 | coords: square 137 | } 138 | }; 139 | }; 140 | 141 | var isItMyTurn = function () { 142 | var filledSquares = 0; 143 | 144 | for (var i = 0; i <= 2; i++) { 145 | for (var j = 0; j <= 2; j++) { 146 | if (currentGame.grid[i][j] !== null) { 147 | filledSquares++; 148 | } 149 | } 150 | } 151 | 152 | if (currentGame.team === 'x') { 153 | return (filledSquares % 2 === 0); 154 | } 155 | 156 | if (currentGame.team === 'o') { 157 | return (filledSquares % 2 !== 0); 158 | } 159 | }; 160 | 161 | ws.on('open', function () { 162 | send({ 163 | action: 'joinGame', 164 | data: null 165 | }); 166 | 167 | setInterval(function () { 168 | send({ 169 | action: 'ping' 170 | }); 171 | }, 20000); 172 | }); 173 | 174 | ws.on('message', function (data) { 175 | data = JSON.parse(data); 176 | 177 | if (typeof handlers[data.action] === 'function') { 178 | handlers[data.action](data.data); 179 | } 180 | }); 181 | 182 | var handlers = { 183 | joinGame: function (data) { 184 | console.log('[%s] Joining game %s as player %s', new Date(), data.gameId, data.team); 185 | currentGame.id = data.gameId; 186 | currentGame.team = data.team; 187 | 188 | if (isItMyTurn()) { 189 | waitThenSend(move()); 190 | } 191 | }, 192 | updateGrid: function (data) { 193 | currentGame.grid = data.grid; 194 | 195 | if (!currentGame.team) { 196 | return; 197 | } 198 | 199 | if (isItMyTurn()) { 200 | waitThenSend(move()); 201 | } 202 | }, 203 | updateWinner: function (data) { 204 | currentGame.winner = data.winner; 205 | }, 206 | updateGameStatus: function (data) { 207 | if (data.status === 'in-progress') { 208 | if (isItMyTurn()) { 209 | return waitThenSend(move()); 210 | } 211 | } 212 | 213 | if (['finished', 'finished-draw', 'abandoned'].indexOf(data.status) !== -1) { 214 | storeFrames(currentGame).then(function () { 215 | currentGame.id = null; 216 | currentGame.team = null; 217 | currentGame.grid = emptyGrid; 218 | currentGame.winner = null; 219 | currentGame.frames = []; 220 | 221 | waitThenSend({ 222 | action: 'joinGame', 223 | data: null 224 | }); 225 | }); 226 | } 227 | } 228 | }; 229 | -------------------------------------------------------------------------------- /db-schema/index.js: -------------------------------------------------------------------------------- 1 | var r = require('rethinkdb'); 2 | var util = require('util'); 3 | 4 | var DB_NAME = 'tictactoe'; 5 | var DB_TABLES = ['games', 'stats', 'bot_stats']; 6 | 7 | var bootstrap = function(Connection) { 8 | return r 9 | .dbList() 10 | .run(Connection) 11 | .then(function(dbs) { 12 | if (dbs.indexOf(DB_NAME) === -1) { 13 | console.log('Creating DB ' + DB_NAME); 14 | return r.dbCreate(DB_NAME).run(Connection); 15 | } 16 | 17 | return dbs; 18 | }) 19 | .then(function() { 20 | return r.dbList().run(Connection); 21 | }) 22 | .then(function(dbs) { 23 | if (dbs.indexOf('test') !== -1) { 24 | console.log('Deleting DB test'); 25 | return r.dbDrop('test').run(Connection); 26 | } 27 | 28 | return dbs; 29 | }) 30 | .then(function() { 31 | return r.tableList().run(Connection); 32 | }) 33 | .then(function(tables) { 34 | var promise; 35 | 36 | DB_TABLES.forEach(function(table) { 37 | if (tables.indexOf(table) === -1) { 38 | console.log('Creating table ' + table); 39 | promise = r.tableCreate(table).run(Connection); 40 | } 41 | }); 42 | 43 | return promise; 44 | }) 45 | .then(function() { 46 | return r 47 | .table('games') 48 | .indexList() 49 | .run(Connection); 50 | }) 51 | .then(function(indexes) { 52 | if (indexes.indexOf('status') === -1) { 53 | console.log("Creating simple index 'status' on table 'games'"); 54 | r.table('games') 55 | .indexCreate('status') 56 | .run(Connection); 57 | return indexes; 58 | } 59 | 60 | return indexes; 61 | }) 62 | .then(function(indexes) { 63 | if (indexes.indexOf('playerSocketId') === -1) { 64 | console.log("Creating multi index 'playerSocketId' on table 'games'"); 65 | return r 66 | .table('games') 67 | .indexCreate('playerSocketId', r.row('players')('socketId'), { 68 | multi: true 69 | }) 70 | .run(Connection); 71 | } 72 | }) 73 | .then(function() { 74 | return r 75 | .table('bot_stats') 76 | .indexList() 77 | .run(Connection); 78 | }) 79 | .then(function(indexes) { 80 | if (indexes.indexOf('state') === -1) { 81 | console.log("Creating simple index 'state' on table 'bot_stats'"); 82 | return r 83 | .table('bot_stats') 84 | .indexCreate('state') 85 | .run(Connection); 86 | } 87 | }) 88 | .then(function() { 89 | return r 90 | .table('stats') 91 | .get('totalMoves') 92 | .run(Connection); 93 | }) 94 | .then(function(totalMoves) { 95 | if (totalMoves !== null) { 96 | return totalMoves; 97 | } 98 | 99 | console.log("Creating stats document 'totalMoves'"); 100 | return r 101 | .table('stats') 102 | .insert({ 103 | id: 'totalMoves', 104 | value: 0 105 | }) 106 | .run(Connection); 107 | }) 108 | .then(function() { 109 | return r 110 | .table('stats') 111 | .get('totalGames') 112 | .run(Connection); 113 | }) 114 | .then(function(totalGames) { 115 | if (totalGames !== null) { 116 | return totalGames; 117 | } 118 | 119 | console.log("Creating stats document 'totalGames'"); 120 | return r 121 | .table('stats') 122 | .insert({ 123 | id: 'totalGames', 124 | value: 0 125 | }) 126 | .run(Connection); 127 | }) 128 | .then(function() { 129 | return r 130 | .table('stats') 131 | .get('abandonedGames') 132 | .run(Connection); 133 | }) 134 | .then(function(abandonedGames) { 135 | if (abandonedGames !== null) { 136 | return abandonedGames; 137 | } 138 | 139 | console.log("Creating stats document 'abandonedGames'"); 140 | return r 141 | .table('stats') 142 | .insert({ 143 | id: 'abandonedGames', 144 | value: 0 145 | }) 146 | .run(Connection); 147 | }) 148 | .then(function() { 149 | return r 150 | .table('stats') 151 | .get('tiedGames') 152 | .run(Connection); 153 | }) 154 | .then(function(tiedGames) { 155 | if (tiedGames !== null) { 156 | return tiedGames; 157 | } 158 | 159 | console.log("Creating stats document 'tiedGames'"); 160 | return r 161 | .table('stats') 162 | .insert({ 163 | id: 'tiedGames', 164 | value: 0 165 | }) 166 | .run(Connection); 167 | }) 168 | .then(function() { 169 | return r 170 | .table('stats') 171 | .get('wonByX') 172 | .run(Connection); 173 | }) 174 | .then(function(wonByX) { 175 | if (wonByX !== null) { 176 | return wonByX; 177 | } 178 | 179 | console.log("Creating stats document 'wonByX'"); 180 | return r 181 | .table('stats') 182 | .insert({ 183 | id: 'wonByX', 184 | value: 0 185 | }) 186 | .run(Connection); 187 | }) 188 | .then(function() { 189 | return r 190 | .table('stats') 191 | .get('wonByO') 192 | .run(Connection); 193 | }) 194 | .then(function(wonByO) { 195 | if (wonByO !== null) { 196 | return wonByO; 197 | } 198 | 199 | console.log("Creating stats document 'wonByO'"); 200 | return r 201 | .table('stats') 202 | .insert({ 203 | id: 'wonByO', 204 | value: 0 205 | }) 206 | .run(Connection); 207 | }); 208 | }; 209 | 210 | r.connect({ 211 | db: DB_NAME, 212 | host: process.env.DB_HOST || 'ttt_db' 213 | }) 214 | .then(function(conn) { 215 | return bootstrap(conn); 216 | }) 217 | .then(function() { 218 | process.exit(0); 219 | }) 220 | .catch(function(err) { 221 | process.stderr.write(util.inspect(err)); 222 | process.exit(1); 223 | }); 224 | -------------------------------------------------------------------------------- /blog/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Build a real-time game on Carina 3 | description: Use Carina by Rackspace and Docker to build a scalable, real-time game server 4 | date: 2016-02-22 5 | comments: true 6 | author: Keith Bartholomew 7 | published: true 8 | excerpt: | 9 | Some people might say that using Docker is all the fun you need in your life. OK, maybe no one says that. But for a web developer, building applications in container environments is about as much fun as you can have while still telling your boss that you’re “working”. Members of our community have ported existing game servers like Minecraft to Carina, but I wanted to explore the process of building a game from scratch to run on a Docker Swarm cluster from Carina. 10 | 11 | First, the big question: **What game should I build?** 12 | categories: 13 | - Docker 14 | - Carina 15 | --- 16 | 17 | > This post was originally published on the now-defunct Carina by Rackspace blog in early 2016. At the time of writing, the Carina product was very limited and the container ecosystem was very different from how it is now. Several of the hacks used in this post would be much more elegantly solved with Kubernetes or other orchestration tools, but for the sake of posterity the post is included here as it appeared in 2016. 18 | 19 | 20 | Some people might say that using Docker is all the fun you need in your life. OK, maybe no one says that. But for a web developer, building applications in container environments is about as much fun as you can have while still telling your boss that you’re “working”. Members of our community have ported existing game servers like Minecraft to Carina, but I wanted to explore the process of building a game from scratch to run on a Docker Swarm cluster from Carina. 21 | 22 | Although I could have tried to build the next Fallout 4 or tried to give [AlphaGo](http://deepmind.com/alpha-go.html) a run for its money, I decided to temper my excitement and build a game with simple rules that would still benefit from a real-time multiplayer experience. And when it comes to games, what’s simpler than tic-tac-toe? That’s it, the task is set: **Build a web-based tic-tac-toe game where people can play against each other in real-time.** For bonus points, players will also be able to see real-time statistics about all the games currently being played. 23 | 24 | ### Planning the stack 25 | 26 | Let’s start at the front. The technology choices are fairly obvious here: use the **Canvas API** to efficiently draw the game board, and use **WebSockets** to facilitate two-way communications between the player’s web browser and the game server. For the simplicity of the demo, we won’t create a fallback for browsers that don’t support WebSockets, but if you want to support IE9 or IE8, you’ll definitely want a way to gracefully degrade the experience for those users. 27 | 28 | The WebSocket clients in the browser need something to talk to, of course. We’ll use **Node.js** and the [`ws`](https://www.npmjs.com/package/ws) library to handle WebSocket communications with players. Now, WebSockets are notorious for slowing down and taking up lots of memory when handling many concurrent connections. To address this potential bottleneck (for when the game inevitably goes viral), let’s plan on having several of these WebSocket handlers available, load-balanced behind **NGINX**. This same Node.js application will also be responsible for storing game data in a database. 29 | 30 | Load-balancing WebSockets gets a little tricky, because the connection itself is only stored on the server that initially accepted the connection. This would be problematic when Server A needs to notify all the players in a game of an update, but one of the game’s players is connected to Server B—Server A has no way to directly communicate with the players on other servers. To address this, all the Node.js containers will connect to a shared **Redis** instance and use the [publish-subscribe pattern](http://redis.io/topics/pubsub) to ensure that messages find their way to the right players, regardless of which server each player is connected to. 31 | 32 | Finally, we need somewhere to store all of our game data and statistics. Because we want to stream statistics to our players live, the [changefeeds feature](https://rethinkdb.com/docs/changefeeds/javascript/) of **RethinkDB** is particularly appealing. 33 | 34 | Here’s a visual recap of all the elements of the full-stack game and how they’ll be communicating with one another. 35 | 36 | ![Tic-Tac-Toe game technical stack](game-stack.png) 37 | 38 | ### Implementation details 39 | 40 | The source code for the entire application is [available on GitHub](https://github.com/ktbartholomew/tic-tac-toe) if you’d like to read through it in more detail. Following are some of the highlights. 41 | 42 | #### Standardize WebSocket messages 43 | 44 | WebSockets are very easy to use in the browser, and have a very small API. Creating and using a WebSocket connection is a simple as this: 45 | 46 | ```javascript 47 | var socket = new WebSocket(webSocketURL); 48 | 49 | socket.addEventListener('message', function (message) { 50 | console.log('Server says: %s', message); 51 | }); 52 | 53 | socket.send('hello'); 54 | ``` 55 | 56 | The only event that will be fired when a message is received from the WebSocket server is the `onmessage` event. The contents of the message are completely arbitrary, so we need to create a protocol of some kind for our messages. I’ll borrow an idea from the JavaScript library [Redux](http://redux.js.org/) and standardize all of the messages around a simple, flexible schema: 57 | 58 | ```json 59 | { 60 | "action": "actionName", 61 | "data": {} 62 | } 63 | ``` 64 | 65 | All messages between the client and server will be a JSON document like this one, with an `action` string property and a `data` object property. The WebSocket handler calls a specific function based on the value of `action` and passes `data` as an argument to that function. We’re using this pattern in both the browser and server. 66 | 67 | #### WebSocket publish-subscribe 68 | 69 | Because the application will have several load-balanced WebSocket handlers, we can’t be certain which individual server is handling a given client’s WebSocket connection. However, we still need to ensure that messages initiated by one server (such as a player’s move in a game) are sent to clients that might be connected to different servers. This situation is a good fit for the [publish–subscribe pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern), which distributes messages to the entire cluster, without knowing which clients are connected to which servers. 70 | 71 | When a browser opens a WebSocket connection to one of the game servers, the server assigns that connection a randomly generated UUID. The server then _subscribes_ to a channel on the Redis server by using the player’s UUID, and sends any messages it receives on that channel back to the player: 72 | 73 | ```javascript 74 | // `this` is an object representing an open WebSocket connection 75 | 76 | // Subscribe to (for example) sockets:7100957a-92f6-4c1d-84f5-52c9ac7e0d52 77 | this.redisClient.subscribe('sockets:' + this.id); 78 | this.redisClient.on('message', function (message) { 79 | // Messages received in this channel are intended specifically for the user 80 | // with this UUID. 81 | this.socket.send(message); 82 | }); 83 | ``` 84 | 85 | Whenever a game server needs to send a message to a player, it _publishes_ a message to the Redis server by using the UUID of the player it wants to reach: 86 | 87 | ```javascript 88 | // The server subscribing to sockets:{socketId} will see this message and relay 89 | // it to its connected WebSocket client. 90 | redisClient.publish('sockets:' + socketId, JSON.stringify(message)); 91 | ``` 92 | 93 | #### Database “schema” 94 | 95 | JSON document stores like [MongoDB](https://www.mongodb.org/) and [RethinkDB](http://rethinkdb.com/) don’t technically have schemas, but we want all of our documents to be consistent so we can reason about the data. Each game that is played on the system is stored as a single JSON document. The document for an in-progress game with two moves looks something like this: 96 | 97 | ```json 98 | { 99 | "grid": [ 100 | [ 101 | "x", 102 | null, 103 | null 104 | ], 105 | [ 106 | null, 107 | "o", 108 | null 109 | ], 110 | [ 111 | null, 112 | null, 113 | null 114 | ] 115 | ], 116 | "id": "00dbd06a-295b-4f83-9feb-bc8e1216d57f", 117 | "next": "x", 118 | "players": [ 119 | { 120 | "socketId": "7100957a-92f6-4c1d-84f5-52c9ac7e0d52", 121 | "team": "x" 122 | }, 123 | { 124 | "socketId": "52d58cad-da9c-417c-9fe0-3346cf5c189e", 125 | "team": "o" 126 | } 127 | ], 128 | "status": "in-progress", 129 | "totalMoves": 2, 130 | "winner": null 131 | } 132 | ``` 133 | 134 | We also have a small table of statistics, in which each document is essentially just a named counter: 135 | 136 | ```json 137 | { 138 | "id": "totalGames", 139 | "value": 470 140 | } 141 | ``` 142 | 143 | As changes are written to this table, they are also streamed to each of the WebSocket servers and then to the connected players. This real-time changefeed is one of the key features of RethinkDB and is trivial to implement on the server. (Seriously, it worked the first time I tried it and I almost fell under my standing desk. [Did I mention I use a standing desk?](https://twitter.com/iamdevloper/status/597794173513834497)) 144 | 145 | ```javascript 146 | r.table('stats').changes().run(conn) 147 | .then(function (cursor) { 148 | // Do whatever you want, I don’t care, they’re your oats. 149 | }); 150 | ``` 151 | 152 | This is what a user sees as the `stats` table is updated and streamed in real-time: 153 | 154 | ![Game statistics increasing](tic-tac-toe-stats.gif) 155 | 156 | ### Deploy the game to Carina 157 | 158 | Finally, the part you’ve been waiting for! Let’s build and run containers so you can run this full-stack, real-time game on your own Docker Swarm cluster. If you’ve been following along in the source code, you might have noticed the `script/` directory, which is [full of Bash scripts](https://github.com/ktbartholomew/tic-tac-toe/tree/master/script). This folder contains all the commands that you need to go from an empty Carina cluster to a running application. We’ll be going through most of them here. All of these commands assume that you’re running them from the root directory of the [GitHub repo](https://github.com/ktbartholomew/tic-tac-toe) and have [configured your terminal environment](https://github.com/ktbartholomew/tic-tac-toe#installation) correctly. 159 | 160 | **Before you start:** The application depends on [overlay networks](https://docs.docker.com/engine/userguide/networking/dockernetworks/#an-overlay-network), a feature that was [recently added](/blog/overlay-networks/) to Carina. You need to use a Carina cluster that was created _after_ February 15, 2016, to have this feature and run this application. 161 | 162 | 1. **Create the overlay network.** Several containers in the application connect to this network, which allows them to communicate across a large Swarm cluster without explicit linking or convoluted `affinity` declarations. 163 | 164 | ```bash 165 | docker network create \ 166 | --driver overlay \ 167 | --subnet 192.168.0.0/24 \ 168 | tictactoe 169 | ``` 170 | 171 | 1. **Create data volume containers.** [data volume containers](/docs/tutorials/data-volume-containers/) are used to store RethinkDB data, Redis data, NGINX config files, Let’s Encrypt certificates, NGINX htpasswd files, and the front-end assets. Create all of the containers at once: 172 | 173 | ```bash 174 | docker run --name ttt_db_data --volume /data rethinkdb /bin/true 175 | 176 | docker run --name ttt_redis_data --volume /data redis /bin/true 177 | 178 | # This is the first container we build that the NGINX container will depend 179 | # on. The node affinity here will ensure that all the other containers, as 180 | # well as the running NGINX container, are always scheduled on the same node 181 | # (and thus the same public IP). 182 | docker run --name ttt_nginx_config_data --env constraint:node==/n1/ \ 183 | --volume /etc/nginx/conf.d nginx /bin/true 184 | 185 | # All the containers that NGINX will use need to be on the same Swarm host 186 | docker run --name ttt_htpasswd_data \ 187 | --env affinity:container==ttt_nginx_config_data \ 188 | --volume /etc/nginx/htpasswd nginx /bin/true 189 | 190 | docker run --name ttt_frontend_data \ 191 | --env affinity:container==ttt_nginx_config_data \ 192 | --volume /usr/share/nginx/html nginx /bin/true 193 | ``` 194 | 1. **Set the RethinkDB web password.** The `${NGINX_RETHINKDB_PASS}` environment variable is written to an Apache-style password file and used by the NGINX container to restrict access to the RethinkDB web console. If the password is empty, the NGINX container doesn’t proxy any traffic to RethinkDB. This is the most secure option if you don’t want anyone to access the RethinkDB web console. 195 | 196 | ```bash 197 | docker run --rm \ 198 | --volumes-from ttt_htpasswd_data \ 199 | httpd \ 200 | htpasswd -bc /etc/nginx/htpasswd/rethinkdb rethinkdb ${NGINX_RETHINKDB_PASS} 201 | ``` 202 | 203 | 1. **Start the RethinkDB server.** Use `--net tictactoe` to add the container to the overlay network we created earlier. 204 | 205 | ```bash 206 | docker run \ 207 | --detach \ 208 | --name ttt_db \ 209 | --env affinity:container==ttt_db_data \ 210 | --net tictactoe \ 211 | --restart always \ 212 | --volumes-from ttt_db_data \ 213 | rethinkdb 214 | ``` 215 | 216 | 1. **Create tables and indexes in RethinkDB.** Create these before the game servers spin up, so that the necessary tables and indexes are already in place. 217 | 218 | ```bash 219 | docker build -t ttt_db_schema ./db-schema 220 | 221 | docker run --rm -it --net tictactoe --env DB_HOST=ttt_db ttt_db_schema 222 | ``` 223 | 224 | 1. **Start the Redis server.** `--net tictactoe` connects the container to the overlay network. 225 | 226 | ```bash 227 | docker run \ 228 | --detach \ 229 | --name ttt_redis \ 230 | --env affinity:container==ttt_redis_data \ 231 | --net tictactoe \ 232 | --volumes-from ttt_redis_data \ 233 | redis 234 | ``` 235 | 236 | 1. **Compile the front-end assets and copy them to their data volume container.** 237 | 238 | ```bash 239 | ./frontend/script/compile-assets 240 | 241 | cd ./frontend/src 242 | 243 | docker cp ./ ttt_frontend_data:/usr/share/nginx/html/ 244 | ``` 245 | 246 | 1. **Build the custom images for the game server and NGINX proxy.** 247 | 248 | ```bash 249 | docker build -t ttt_app ./app/ 250 | 251 | docker build \ 252 | --build-arg affinity:container==ttt_nginx_config_data \ 253 | -t ttt_nginx_proxy ./nginx/ 254 | ``` 255 | 256 | 1. **Start a few Node.js containers.** [`script/start-app`](https://github.com/ktbartholomew/tic-tac-toe/blob/master/script/start-app) helps you start multiple containers in a blue/green deployment pattern. Each container is connected to the overlay network with `--net tictactoe` Here’s what it’s doing: 257 | 258 | ```bash 259 | #!/bin/bash 260 | 261 | # Usage example: script/start-app 3 blue 262 | scale=${1:-1} 263 | color=${2} 264 | 265 | for i in $(seq 1 ${scale}); do 266 | docker run \ 267 | -d \ 268 | --label color=${color} \ 269 | --name=ttt_app_${color}_${i} \ 270 | -e affinity:image==ttt_app \ 271 | -e REDIS_HOST=ttt_redis \ 272 | -e RETHINKDB_HOST=ttt_db \ 273 | --net tictactoe \ 274 | --restart=always \ 275 | ttt_app 276 | done; 277 | ``` 278 | 279 | Run `script/start-app 2 blue` to start two Node.js containers with the “blue” deployment label. 280 | 281 | 1. **Add the Node.js containers to the NGINX configuration file.** [`script/update-nginx`](https://github.com/ktbartholomew/tic-tac-toe/blob/master/script/update-nginx), invoked as `script/update-nginx [blue|green]` is a Node.js script that finds all the running game servers (filtering by the color argument if provided), adds them to an `upstream` load-balancing block in the NGINX configuration, and sends a SIGHUP signal to the NGINX container to have it reload the updated configuration. 282 | 283 | Run `script/update-nginx blue` to add all of the “blue” Node.js containers you just created to the NGINX configuration file. 284 | 285 | 1. **Start the NGINX container.** Add it to the overlay network so it can access all the other running containers. That’s especially important for this container because it proxies traffic to several of the other running containers. It also needs `affinity:container` arguments to ensure that it is scheduled on the same node as all the data volume containers that it needs to access. Publish its ports 80 and 443 (HTTP and HTTPS) on the Swarm host so it’s publicly accessible. 286 | 287 | ```bash 288 | docker run \ 289 | -d \ 290 | --name ttt_nginx_proxy_1 \ 291 | -p 443:443 \ 292 | -p 80:80 \ 293 | --env affinity:container==ttt_frontend_data \ 294 | --env affinity:container==ttt_nginx_config_data \ 295 | --env affinity:container==ttt_htpasswd_data \ 296 | --env affinity:container==ttt_letsencrypt_data \ 297 | --net tictactoe \ 298 | --restart always \ 299 | --volumes-from ttt_frontend_data \ 300 | --volumes-from ttt_nginx_config_data \ 301 | --volumes-from ttt_htpasswd_data \ 302 | --volumes-from ttt_letsencrypt_data \ 303 | ttt_nginx_proxy 304 | ``` 305 | 306 | 1. **Get the public IP address of the NGINX proxy container.** 307 | 308 | ```bash 309 | docker port ttt_nginx_proxy_1 310 | ``` 311 | 312 | After you have the public IP address of that container, visit it in your browser (use two tabs to play against yourself) and [enjoy a game of tic-tac-toe](https://tictac.io/)! 313 | 314 | ![Tic Tac Toe Web UI](web-ui.png) 315 | 316 | This is probably a bad time to tell you that you could have run `script/setup` from the GitHub repo and done all of that work in about 30 seconds. But if you had just run that one script, you wouldn’t know all the cool stuff happening behind the scenes. Hooray you, for being well-informed! 317 | 318 | ### Next steps 319 | 320 | You’ve just created a fairly complex application on Carina, taking advantage of the new overlay networking feature to make communicating between containers easier than ever. The application as it stands should be able to handle quite a bit of traffic, thanks to the performance characteristic inherent in each of the components. So what’s next? 321 | 322 | * [**Improve the bot.**](https://github.com/ktbartholomew/tic-tac-toe/issues/1) The GitHub repo [includes a bot](https://github.com/ktbartholomew/tic-tac-toe/tree/master/bot) to facilitate load testing, or just to prevent you from having to play against yourself. The bot was hastily written, occasionally it just stops playing. Improving this bot could help you test the application under heavy load, or give you a chance to flex your machine-learning muscles. 323 | * [**Scale the database.**](https://github.com/ktbartholomew/tic-tac-toe/issues/2) The single RethinkDB container has its limitations, but RethinkDB clustering and data sharding are fairly easy to implement in a container environment. Try dynamically scaling the database and ensuring data availability as cluster members come and go. 324 | * [**Build a more resilient messaging system.**](https://github.com/ktbartholomew/tic-tac-toe/issues/3) The current messaging system is 100% ephemeral, meaning it’s very likely that a subscriber will not receive a published message, the client will never receive the message, and their game could potentially be stuck forever. Try building a messaging system that’s more resistant to network hiccups and heavy load. 325 | --------------------------------------------------------------------------------