Clodhoppers
Over summer 2024 I worked as an intern at Claymatic Games on their game Clodhoppers. My work wasn’t on the game itself, but a variety of associated tools and systems.
TL;DR
Developed:
- Internal library for collection of time and player scoped analytics
- Dashboard for display of community and player-specific stats, including privacy-preserving opt-in
- Feedback form that integrates with Sentry for automatic error collection
- Beta signup form that integrates with Google Sheets and Discord
Things I’m proud of:
- Employers impressed with my work and asked me to stay on after internship ended to develop an additional system
- Fully responsible for design, implementation, and deployment of the systems I made
What I learned:
- Rapid iteration is super valuable when you’re developing for someone else
- The ability to ask questions and get responses quickly is really important when you’re learning something new
Analytics System
The first project I worked on was an analytics system for the game. Claymatic wanted to be able to collect data on gameplay events for a few purposes:
- Usage in other in-game systems
- Community building and player engagement
- Balancing and tuning the game
- Monitoring user engagement and progression
Internal Library
When I started this project there was no system for collecting metrics, so I started by building a library for this purpose. Initially the class had fields for all the metrics that needed collecting - but this quickly became bulky and inflexible. In the end I decided upon a simple map of string -> int. However, there were many convenience functions placed on top of this.
Sub-counters
A simple counter might look like:
analytics.Increment("Kills", 1);
But what about if we want to track kills per weapon? A naive approach might look like:
analytics.Increment("ShotgunKills", 1);
analytics.Increment("AxeKills", 1);
But what if we need to count all kills? We’d need to separately maintain a list of all counters that correspond to kills, which adds complexity.
The solution to this is a simple hierarchical naming scheme. In this system the above would look like:
analytics.Increment("KillsByWeapon.Shotgun", 1);
analytics.Increment("KillsByWeapon.Axe", 1);
Then, before the metrics are sent to an external service for storage they are expanded into a dictionary like this:
{
"KillsByWeapon": {
"Axe": 7,
"Shotgun": 5
}
}
Scopes
Counters are scoped by two things: by time-context, and by player. The simpler of these is by player: counters can be either scoped to a specific player, or scoped to the install:
// Scope to install
analytics.Increment("GamesPlayed", 1);
// Scope to a specific player ref
// (range 0-n for each player in the current game)
analytics.IncrementPlayer(new PlayerRef(1), "Kills", 1);
Any player-scoped counters are also automatically scoped by time-context:
// How many kills player 1 has for the whole lobby's existence
analytics.GetCounterGame(new PlayerRef(1), "Kills");
// How many kills player 1 has in the current match
analytics.GetCounterMatch(new PlayerRef(1), "Kills");
// How many kills player 1 has in the current round
analytics.GetCounterRound(new PlayerRef(1), "Kills");
Timers
All of the above functionality also works for timers, which can be started/stopped and provide the number of seconds they’ve been enabled for:
analytics.StartTimerPlayer(new PlayerRef(1), "TimeAlive")
analytics.StopTimerPlayer(new PlayerRef(1), "TimeAlive")
analytics.GetTimerPlayer(new PlayerRef(1), "TimeAlive")
Community Dashboard
With this newly surfaced data, I built a publicly available dashboard that shows various interesting stats for the Community as a whole. It can also show stats for a specific player, if they’ve opted in to sharing their data inside of the game.
Tech Stack
Front End
The dashboard uses next.js and react for the front end. It obtains the stats serverside from a JSON file. It also makes queries to the backend before showing player-specific data to ensure they’ve opted in to public data visibility.
Back End
The backend is a Rust webserver that fetches new events from the third-party analytics platform and aggregates them and stores them in the JSON file that the front end reads from. It also processes requests from the game to enable/disable public data visibility for a player. These requests are verified using the Steam API to ensure they haven’t been forged.
Observability
Claymatic wanted a way to collect feedback from players so they know what to work on. I’d used Sentry in a personal project before and suggested that if we sent feedback to Sentry we could attach error logs, as well as automatically report bugs without players having to do anything.
The feedback form uses the same frontend as the dashboard, just a different page. It submits feedback both to Sentry and to the backend. The backend then pushes feedback to a Google Sheet for easy viewing of non-bug related feedback. Feedback on specific bugs can be viewed in Sentry where it’s automatically linked to all the errors that occurred during the player’s session. This is done by a query parameter being included when the game opens the feedback page.
Beta Signup System
During my internship, Claymatic was busy preparing Clodhoppers for beta testing. Their initial plan was to use a simple Google Form to collect applications - but they were impressed with the work I did for them and decided to ask me to stay on after my internship to build a custom system for this purpose.
Requirements
- Connect to applicant’s Discord to automatically join them to the server.
- Must be easy for non-technical team member to view and accept applications.
- Notify accepted applicants over Discord.
- Accepted applicants automatically given access to correct Discord channel.
Solution
Signup form has two pages - one with initial information, and the second with a bunch of fields, including a “Connect Discord” button. Submitted form is submitted to Rust backend, where it’s pushed up to Google sheets and the user is joined into the Discord server. One of the columns on Google Sheets is “Invited” which contains a checkbox. When this checkbox is ticked it is detected by the backend. The backend takes note of this update and sends a Direct Message on Discord to the user. The message contains some terms & conditions. The user then clicks “Accept” and the backend provisions a Steam key from a provided file and sends it to them. It also allocates them a role on the Discord server providing access to the beta channel.
Iteration
Since this form will be in the way of users who want to play the game during beta it was important that it was optimised to fit the brand image and explain everything clearly and simply to minimise friction and encourage signups. This involved multiple back-and-forth conversations with designers and the person responsible for the beta program. During this I learned the value of being able to iterate quickly and show off potential changes within minutes of them being suggested.