{"id":16774495,"url":"https://github.com/parasyte/pn-candybox","last_synced_at":"2025-04-10T20:07:14.915Z","repository":{"id":8708232,"uuid":"10375586","full_name":"parasyte/pn-candybox","owner":"parasyte","description":"Candy Box with Real-Time statistics","archived":false,"fork":false,"pushed_at":"2013-06-01T01:21:17.000Z","size":292,"stargazers_count":8,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2025-03-24T17:52:40.869Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/parasyte.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2013-05-30T04:46:31.000Z","updated_at":"2024-11-28T16:27:16.000Z","dependencies_parsed_at":"2022-07-09T21:30:41.844Z","dependency_job_id":null,"html_url":"https://github.com/parasyte/pn-candybox","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parasyte%2Fpn-candybox","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parasyte%2Fpn-candybox/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parasyte%2Fpn-candybox/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parasyte%2Fpn-candybox/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/parasyte","download_url":"https://codeload.github.com/parasyte/pn-candybox/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248288345,"owners_count":21078903,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-13T06:49:16.967Z","updated_at":"2025-04-10T20:07:14.893Z","avatar_url":"https://github.com/parasyte.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Real-Time Stats for Candy Box!\n\nWe like games here at PubNub, but not as much as we like real-time. Combine the\ntwo, and you've got pure mega-awesome. During the PubNub Hackathon, Jay Oster\ntook a popular text adventure game called Candy Box, and updated its stats page\nto provide a real-time overview of the global game statistics. The updated game\ncan be played at [candybox.pubnub.co](http://candybox.pubnub.co/index.html), and\nthe new real-time stats page is [here](http://candybox.pubnub.co/stats.html).\nFull source code is available\n[on github](https://github.com/parasyte/pn-candybox).\n\nIn this article, we'll guide you through how the game was modified, and how to\nbuild a very simple, yet hyper-scalable server infrastructure to serve real-time\nstatistics. Today we're using JavaScript on the client-side, and Python on the\nserver-side. So let's dig in!\n\n![Castle Entrance](http://i.imgur.com/ORRfYin.png)\n\nSince Candy Box is a very new game, there isn't a public source code repository\navailable. So I started this project by mirroring the original website,\n[candies.aniwey.net](http://candies.aniwey.net/), with wget. It's also possible\nto load the URL into a browser and use File-\u003eSave Page As... to get a complete\nlocal copy of the game.\n\n\n## Download the Game Code\n\nWith all of the JavaScript downloaded, I had to make some minor adjustments in\nsome places to get a few things working correctly. Loading and saving had to be\nmodified; moving some of the original server-side logic right into JavaScript.\nI'll spare the details, and the curious among you can have a look at\n[load.js](https://github.com/parasyte/pn-candybox/blob/master/public/js/load.js)\nfor some insight. To allow saving, the stats server just proxies save requests\nto the original server. The proxy is required because the original server does\nnot support [CORS](http://en.wikipedia.org/wiki/Cross-origin_resource_sharing),\nmeaning newer browsers will reject the save attempt.\n\n\n## Building the Stats Server\n\nWith the baseline game ready, it's time to start thinking about the server-side,\nparticularly how to handle the high load that such a popular game will generate.\nHigh load is the reason the original stats page is updated only infrequently.\nThe challenge is to record and report game statistics for thousands of\nsimultaneous players in real-time, with very frequent updates (refreshing stats\nabout once every second). With this in mind, I came up with the following list\nof requirements while designing the server:\n\n* In-memory statistics aggregation\n* Horizontally scalable\n* Stateful-design, but distributed\n\nSounds simple, right? Do calculations in memory to make it fast, and scale\nhorizontally to handle any load. A panacea! But how can we sum and average\nhundreds of thousands of accounts per second? Well, we can cheat a bit by doing\nthe summing on the client-side, and only keep the result of the sums on the\nserver. In other words, client logic will be responsible for calculating the\ndeltas between each status update, so the server just needs to worry about\nadding those deltas into its internal state. Therefore, each status update is a\nsnapshot in time, and the server records only the sum over all snapshots.\n\nTo share state between servers, we'll use the same idea; deltas between updates,\nand periodically publish to a common PubNub channel to distribute changes to the\ninternal state. This method of replication introduces its own challenges, such\nas update latencies with multiple servers. This is acceptable because the\nstatistics in this game are quite volatile anyway. No one will notice any\nlatencies.\n\n\n### Server Implementation Details\n\nFor the server, I decided to use [Bottle](http://bottlepy.org/) to handle the\nREST interface, and [gevent](http://www.gevent.org/) for non-blocking sockets.\nThis will give us a great deal of flexibility for the server.\n\nAfter writing a few stubs for the REST interface, I started on the distribution\nmechanism, which is just a PubNub subscribe that handles messages from other\nservers. Ideally, each server will periodically share the updates they have\nreceived from clients. The server only needs to add deltas from the user to its\nown internal delta for distribution to other servers. It can't distribute every\nclient request, because traffic would be much too high. And it would defeat the\npurpose of horizontally scaling, anyway. This is also where distribution latency\ncomes in; a delta from a client may take a few seconds to reach every server.\n\n\n### Integrating PubNub\n\ngevent makes the subscribe super easy; just the normal `Pubnub.subscribe()`,\nwrapped in a call to `gevent.spawn()`. There are initially two such subscribes:\na \"control\" subscription, which will recursively re-subscribe after handling\neach message, and a \"sync\" subscription to initially synchronize with other\nservers.\n\nThe \"control\" subscription does three things:\n\n1. Respond to \"sync\" requests; providing the current game state to other servers\n1. Update config; allowing you to remotely reconfigure all servers\n1. Update game state; receive deltas from other servers\n\nI'll explain more about the config updating later. After Both subscriptions are\nestablished, a \"sync\" request is published to any listening servers. The first\n\"sync\" response that comes within 5 minutes will cause the game state to be\nupdated with the provided values.\n\nThe server then goes to sleep until a user makes an \"update\" request. That will\nstart a recursive timer (the interval is configurable) which will send stat\nupdates to clients, and stat delta updates to other servers. The timer recursion\nautomatically stops 5 minutes after the last user \"update\" request.\n\nAnd that's about all there really is to the server. You could also do some other\nfancy things like persisting the state to disk periodically. It isn't a lot of\ndata to store, but these stats are also not critical, especially for the demo.\n\n\n### Running the Stats Server\n\nStarting the server is easy as well, just start the main script with python, or\nrun it directly in a shell (it has exec permissions and a shebang). The script\naccepts three optional arguments for listening IP, listening port, and config\nfile. The config file is just JSON, in the same format as in\n[config.py](https://github.com/parasyte/pn-candybox/blob/master/server/config.py)\n\nHere's an example:\n\n    $ python main.py 0.0.0.0 8999 ~/config.json\n\n\n## Hacking the Client\n\nBack on the client-side, we just need a function to record the deltas, and\nanother to send the \"update\" requests to the server. I decided to use the\n[localStorage API](https://developer.mozilla.org/en-US/docs/Web/Guide/DOM/Storage#localStorage)\nto record the update state between each request, allowing the deltas to be\ncalculated correctly even after restarting the browser.\n\nAs far as security goes, I will be ignoring the possibility of cheaters for the\ndemo. Stats can also be skewed by saves that have completed the game, because\n**SPOILER ALERT** the computer tab grants access to generating candies and\nlollipops at an impossible rate, and changing pretty much every variable in\nstrange ways. **SPOILER ALERT**\n\nClient requirements are as follows:\n\n* Turn a blind eye to cheaters (simplifies everything)\n* Periodically send \"update\" requests (once every 5 seconds is a good start)\n* Do not send \"update\" requests after the game has been completed\n\nThe update interval will be once every 5 seconds by default, which will be quick\nenough to affect the stats updates that users end up seeing, and slow enough to\nhandle a large number of simultaneous players with low server resources; With\n2,000 users, the server only needs to handle 400 requests per second. The\ngevent-based server will *easily* handle that without a hiccup, even on\ncommodity hardware. In fact, each server should handle about 1,000 concurrent\nconnections. If more than 5,000 users are playing, just launch another stats\nserver and put it behind nginx (reverse proxy) as a load balancer. More on that\nlater.\n\n\n### The Hook\n\n[main.js](https://github.com/parasyte/pn-candybox/blob/master/public/js/main.js)\nis where the game loop runs. It's implemented as a simple interval that fires\nonce per second. This is the place to add the stats updates. The code is very\nsimple; just throttle a function call to once every 5 seconds:\n\n```javascript\n// Save to PubNub CandyBox stats server periodically\nif ((this.nbrOfSecondsSinceLastMinInterval % 5) === 0) {\n    stats.update();\n}\n```\n\nThe `stats.update()` function is where the magic happens. It records the\ninteresting bits of game state, calculates the delta, and sends the request to\nthe stat server.\n\n![Real-Time update by PubNub](http://i.imgur.com/Y9PIHGp.png)\n\n\n### Delta Calculation\n\nThe delta calculation is very easy (as you might imagine). I just keep a record\nof the last game stat after a successful \"update\" request (and save this object\nto localStorage), and the delta is calculated with a small iterator:\n\n```javascript\n$.each(currentUpdate, function (k, v) {\n    if (typeof(v) !== \"string\")\n        delta[k] = lastUpdate[k] - v;\n});\n```\n\nShould be self-explanatory, but basically the difference between values in\n`lastUpdate` and values in `currentUpdate` are recorded as the `delta`, with a\nsafety net for the `code` key (not shown) which is a string value. The `delta`\nis then sent to the stats server in an \"update\" request.\n\nThe server does its work, and periodically publishes a message for the stats\npage. The listener code is in\n[stats.js](https://github.com/parasyte/pn-candybox/blob/master/public/js/stats.js)\nand you can see it does the percentage calculation client-side. It is otherwise\nincredibly basic.\n\n\n## Server Configuration\n\nWith the client and server ready to go, it's time to start thinking about the\noperational side of the project; configuring servers, DNS, an even dynamically\nscaling and remote-control reconfiguration.\n\nI'm using [nginx](http://nginx.org/) as a host for the client code and it also\ndoubles as a front-end load balancer for the stats server. The nginx config\nlooks like this:\n\n```nginx\nupstream stats_server {\n    server localhost:8999;\n    #server localhost:8998;\n    #server localhost:8997;\n    #server localhost:8996;\n\n    keepalive 32;\n}\n\nserver {\n    listen 80;\n\n    root /home/ubuntu/pn-candybox/public;\n    index index.html;\n\n    server_name candybox.pubnub.co;\n\n    location /ping {\n        proxy_pass http://stats_server;\n    }\n\n    location /save {\n        proxy_pass http://stats_server;\n    }\n\n    location /update {\n        proxy_pass http://stats_server;\n    }\n\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n}\n```\n\nI did some load testing with ApacheBench and found that nginx with a single\nstats server can handle about 763 requests per second with 100 concurrent\nconnections, or about 305 requests per second with 200 concurrent. All tests\nwere done on a t1.micro AWS instance (E5507 @ 2.27GHz, 589MB RAM) running Ubuntu\n13.04 with *no TCP kernel tuning*. This setup is good enough for our \"2,000\nsimultaneous players\" requirement.\n\n\n### Dynamically Scaling\n\nWith the server config in place, we can easily scale up by adding more upstream\nstats servers (commented in the config above). Then reloading nginx. The stats\nservers will automatically synchronize with one another over PubNub. We can also\nreconfigure the servers at runtime to tune the message publishing rates. I just\nhave to open the [PubNub console](http://www.pubnub.com/console) and publish a\nspecially constructed message to the \"candybox_update\" channel. Here's an\nexample message that reconfigures the servers to publish only once every 5\nseconds:\n\n```json\n{\n    \"uuid\"      : \"master\",\n    \"action\"    : \"config\",\n    \"data\"      : {\n        \"update_interval\"   : 5\n    }\n}\n```\n\nPublish that message, and all servers will instantly adjust their message\npublishing interval to 5 seconds. This is just one example of what makes PubNub\ntruly awesome. :)\n\n\n## Wrapping Up\n\nWith all of that, we now have Candy Box sending periodic updates to our stats\nserver, and our stats servers periodically sending updates to the stats page.\nAnd it's all done in a dynamically scalable way, with a ridiculously small\nmemory footprint, and low bandwidth requirements.\n\nAll done! Now you should [play the game](http://candybox.pubnub.co/index.html),\n[check the stats](http://candybox.pubnub.co/stats.html), and\n[fork me](https://github.com/parasyte/pn-candybox)!\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fparasyte%2Fpn-candybox","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fparasyte%2Fpn-candybox","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fparasyte%2Fpn-candybox/lists"}