Spiral Game System Website2021-10-12
A web tool for designing games, characters, and items for the Spiral Game System, Voidspiral’s next Magnum Opus.
The SGS website is basically analog of D&D Beyond for SGS. It allows you to create content for games, or even create entire games themselves using SGS.
The business goals of this project include:
- Creation and editing of all game rules
- Game statistics
- real-time collaboration by multiple users on different platforms
- Provide an interface for use by Players and GMs to use while the game is running
- allow users to join & collaborate on different rules docs
- limit access to certain content and allow mods to make changes to documents
- Continuous Delivery
The site is built on Node.js. It’s divided into three parts:
- React handles content display
- Express handles the typical server stuff
- Socket.io handles the realtime communication.
- MongoDB handles storing the data instead of a traditional database
- MongooseJS provides templating for MongoDB objects
- JWT provides auth tokens
- SGS Core
- Included as a module in Frontend
- Does the business math
- Calculates formula-based statistics
- Tabluates entity XP totals
- Sums dice pools, bonuses, and other variables
- Concatenates parent documents, so that child documents can use their content
In terms of how the content works on the site, there are two levels.
- The top-level structure is handled in “docs” which are self-contained rules documents. Each doc can be processed using the
- Each doc has an original owner, who can then promote other users to editors of that doc. Otherwise, no other user can edit the doc, but they can view it.
- Docs can be made parent or child of other docs, allowing users to make a Campaign level doc that refers to a Game level parent, and so on.
- Docs can have various states of visibility and deletion.
- Inside each doc are prototype rules and entities. Spiral Game System treats everything as an
entity, and calculates XP values like a wargame using the exact same formula for all entities.
- That provides a system where all stats, skills, powers, items, PCs, NPCs, monsters, and vehicles are all accurately valued with the same XP points. Most of the rules of the game, and thus most of the programming focuses on the entities.
Navigation around the site occurs in two layers parallel to docs and entities.
- Top-level navigation is handled by React Router, which shows the user various pages based on their navigation.
- This includes going from the dashboard to the docs list, for example.
- Doc-level navigation happens inside the context of a single doc.
- Doc-level navigation is handled as a second-level router.
- Doc-level navigation is almost instant, because the app uses Socket.io to handle doc-level requests.
- Once a doc is opened by a user, that doc is cached on the server, and all future users who navigate to that doc are fed the same doc data.
- Requests made by users on a doc page are sent to a
socket doc apithat handles requests by performing operations on the cached doc, then sending that doc out simultaneously to all connected users.
Dev-ops was a major part of getting this project to work properly, and was actually part of the reason I switched from C# on SGS Console. It became too difficult to collaborate between users on SGS-Console, so I moved to a technology where collaboration would be easier to implement, and delivery of the project to users would be more seamless.
Anyway, here’s how it works.
- triggers Heroku Production build on push
- triggers Heroku Staging build on push
- basis for new patches, features
- used for emergency bugfixes
- creation of new features
- delays any changes to Staging or Production until stable
- kept until upstream is stable with changes
- deleted occasionally to cleanup remote and local
Master builds are notified on our Discord server’s SGS channel1. I usually try to pop in and explain what the changes were, if they were noticable for our test users.
This project started life after I found deficiencies in how I was trying to develop its previous iteration, SGS Console. I never felt like I wasted the work on SGS Console2, because I learned a ton during that project, and I barely had to make any real decisions for the first few months of development on the website, because I’d already made those decisions on Console. It was very easy to use Console as my roadmap and quickly work up a roadmap for the new iteration.
While I was still working on Console, I started to experiment with using other technologies to accomplish my goals of real-time communication and continuous delivery. I started with Socket.io, and quickly got the example chat app working, despite my initial trouble with React. Once that was working, I tried out Heroku, then MongoDB. With those three technologies working, I was ready to actually start thinking about architecture for a new iteration. There were a few more small experiments along the way that helped, including for ReactRouter, but after the first 3 I was off to the races designing out how the new app would work. That’s about when the idea of analoging D&D Beyond became viable.
From there, instead of breaking up the project into numerous milestones and carefully defining every component, I broke things up into the very rough categories of mission critical, on deck, and enhancements. That allowed me to get right to work building out the structure of the site and get working on the core bits of database manipulation and router navigation within days. The app rapidly evolved from there. Once the socket doc system was in place, I was able to start experimenting with it to build games and invited some friends to try it out. Their input and testing proved invaluable, as they helped reveal a lot of design decisions I’d yet to make. By that time I was much further along than the original Console app, so I was starting to make new decisions about how thi iteration would function, and what operations would be allowed. It also forced me to start thinking about auth. Here was another side-experiment where I tried some pre-packated solutions that didn’t work and came around to building my own implementation of JWT authorization (both for react-router top-level nav, and for socket-doc operations).
From there, things really took off. By that time the app was stable enough to use for real game design and that again fueled more new features and bugfixes. Lather, wrinse, and repeat. Now, features are largely driven by what’s wrong with the current way of doing things, or by features that would be even more convenient.
- Until this project I never grokked React. It was always a land of mystery. But now I have conquered that land3. I get how I’m supposed to think about data in a one-directional flow. It makes a LOT more sense when working on a project where data must be provided from the server, and operations need to go up a different route.
- This was my first project that was “fullstack.” I’ve never written a server before, and though I’d had some experience in the oughts with PHP and other server-side scripting, nothing was like this. Fortunately, Express is very easy to learn, and it became a simple matter for me to divide up server operations into domains and chain together more and more requests in the HTTP and Socket systems.
- Devops at this level was also a thing I had to spend time figuring out. In my days as a corporate software designer, we had excellent devops handled by mysterious “devops people.” Now, I wear that cloak too. Thank goodness that toolchains exist for the things I’m trying to do.
- Changing my thinking to a more functional programming mindset has been an adjustment. The modern JS world is more about lambdas than I was sready for, but thanks to that, I finally understand those too. That has already helped me out in other projects.
Points of Interest
This function is the meat and potatoes of the SGS module.
- First, it preemptively populates a list of item objects that the entity owns from a sparse list of references, so that it’ll have them later for calculating XP and generated stats.
- Next, it iterates over each of the stat groups and populates entity stats with properties from game-level stat prototypes, including Stat formulas, Skill categories, and Skill Primary Stat references.
- While performing that loop, it also calculates the value of generated statistics, by calling the
calcNodeon the stat’s formula.
- next it begins tallying up XP for that entity. It keeps track of each group of stats separately, so that that information can be displayed on the UI for users to balance the entity’s XP value.
- After stats and item XP values are tabulated, it applies any limitation value modifiers to the overall XP value, and then rounds the total to an integer.
- Next, it counts up the XP values of skills related to each stat and stores those values, so that users can see which skills the entity is most invested in, and where its primary stats should be placed.
- Finally, it finds the sum of
Skill + Statfor each Skill and stores it, so that users don’t have to manually calculate that in their heads every time a roll comes up.
- While performing that loop, it also calculates the value of generated statistics, by calling the
- Lastly, it totalls the XP value of ALL entities, so that it can be used as a rough measure of the size of the doc.
The DicePool class performs the extremely complex job of combining an arbitrary amount of numbers, dice pools (5d6, 2d20, etc4), die sizes (d4, d6, d8, etc), and operations (+, -, ×, ÷).
Here are a couple of interesting cases I had to design for.
- 1d6 + 5 = 1d6+5
- 1d8 - 2 = 1d8-2
- 1d6 × 15 = 15d6
- 1d10 ÷ 10 = 1d10/10
- 2d8+5 ÷ 7 = 2d8+(5/7)
- 1d6 + 1d8 + 3d6 + 4d8 = 4d6 + 5d8
Not all of these cases are easy to solve in a generic way that can be easily applied to an arbitrary set. And not all of the complicated cases are this obvious.
The DicePool class approaches this in phases. Its constructor takes a single string containing any series of values and operators. It throws errors on malformed constructors, ie anything that isn’t in the format of
Value1 Operator1 Value2 Operator2 Value3 ... OperatorN ValueN, numbers that are NaN, mismatched parentheses, invalid characters, etc.
Once it has clean data to work from, it sets to work tokenizing the string. The tokenizer iterates over the string character by character and keeps track of explicit parenthetical levels. The tokenizer builds a tree of
Parenthetical objects from the data. An example tree might look something like this:
- Node (root)
- Node (child 1)
- Value: 1d6
- Operator: -
- Value: 5
- Operator: +
- Node (child 2)
- Value: 2d6
- Operator: -
- Value: 10
- Node (child 1)
The Parenthetical has some methods that help clean up this mess.
collapse()removes redundant nodes, ie those that contain only one value.
segment()breaks up nodes based on implicit parentheticals, ie dividing
reduce()combines groups that might be calculable together, so that
1d6+1+1d6will later come out to
calculate()performs operations on nodes and attempts to give the root node a
minvalue from any dice values. It does this depth-first on each node, building up the root from the lowest children.
After collapse is run a final time for cleanup, the dice pool is finalized. The final dice pool object can be observed for the following:
toString()provides a re-formatted text string of the original constructor:
toObjectOrNumber()provides either the DicePool object or a single integer, if one could be made from the original input. This is useful for the many ways a DicePool can be displayed in the UI and print.
average()produce the appropriate values from dice pools. If the DicePool came out to a single integer, the value, max, min, and average will all be the same.
The Doc Socket API handles when a user clicks a button or changes a value on a doc by performing operations on the cached doc.
Note: A higher-level
docSocketApiand handles using JWT to auth every request, as well as returning the mutated doc to users observing the page.
This module only performs the requests.
First, the the module checks to make sure that the request object delivered from the client has an
operation field. If not, it doesn’t know what operation to perform, so it simply returns. Since this is occuring in the socket system, no push to the connected clients is made.
Next, the operation is passed to a series of seperate operations handlers. Each handler checks to see if it owns the operation, skips if it does not, and performs work if it does. The handlers are divided up into:
statOps: operations performed on the game’s prototype statistics
relOps: operations performed on the document’s relationships, ie parents and children
fileOps: operations like saving the document or setting it’s state
entityOps: operations pertaining to creating renaming, or removing entities.
userOps: operations that edit the doc’s editors list
Of course, each operation has a different task and works in a slightly different way, but they generally follow this pattern:
- Check to make sure that any variables needed to perform the operation were included in the request
- Filter down to find any target entities or statistics
- Perform changes by adding, removing, or editing fields
- Mark the doc dirty, unless the operation was “save”
On success, the modified doc is sent back up to the socket api, who then sends the document down to the users. At no point is the document actually returned to the server, avoiding the need to sanitize a huge blob of JSON data.
- Testing. As ever, I don’t have a huge amount of time and resources to devote to this project, so the only parts that have automatic test cases written are the SGS core parts. I’ve been relying on my human testers and my own dev process to catch any UI related problems.
- Interoperability between ES6 and CommonJS modules has been a pain since I started this thing. I still don’t have a simple way to get the SGS module (an ES6 module) into the Server, but fortunately the Server doesn’t need to know much about SGS.
- Upstream Issues exist that cause problems with Socket.io on Chrome5. I don’t have the foggiest idea how to fix those issues on my end, so for now it’s just better to use the site on firefox.
- MS Surface somehow hates the way that Node.JS does Symlinks, so I can’t install the project on my laptop and develop outside the office, which is a bummer.
What I learned
Oh man, what didn’t I learn on this project. This has been one of the best learning experiences of my development career.
- Finally grokked React
- Learned about SPA routing
- Learned how to write a web server
- Learned about functional programming
- Learned some stuff about networking
- Learned about websocket connections
- Learned how to structure node modules
Check it out here: https://sgs-web.herokuapp.com/
I do contract work! Contact me via social media or shoot me an email at
Joe at Voidspiral com!
and I really try not to spam people, hence the infrequent changes to master, only when stable. ↩︎
and let’s face it, ain’t nobody was gonna use a command line app for game design. ↩︎
Okay, strong words, maybe I’m more like a citizen now. ↩︎
Technically this class by itself could support any arbitrary die size, like 3d333 or 1d1 or 50d1203, but we validate out non-real die sizes higher up in the call stack. ↩︎