Modding Rising Constellation - Plugin Architecture
I got to the point of modding where I wanted others to try using these mods and also write mods. The way I was modding the game was not easily distributable, durable against updates, maintainable, easily extendable, and more issues. It was, in pure essence, a lab bench with bespoke tools and equipment all over.
Goals for Modding
Before I did anything, I needed to ask myself, “if I were a user of these mods, what would I want”?
Several needs manifested themselves as clear goals to achieve
- Central place to find and download mods for the game
- Easy to install and update downloaded mods; remove them and see what’s installed
- User-code easily written and separate from the game code
With these needs, I also asked myself what would I as the platform developer for these mods need to achieve these goals? Some more needs surfaced through tinkering with designs
- Clear separation of mods to improve readability, writability, and fault-tolerance
- Easily identify what mods are present locally and load them
- Expose a simple interface for modders to hook into as needed
There are other minor points in both those above lists not written here, but these six principles helped me to get to the first design that satisfied these goals.
Plugin Architecture
The modding platform has four distinct pieces
- Original files modified as minimally as possible to interface with my
HookDispatcher
. HookDispatcher
, responsible for loading mods, receiving game events from the original files, and forwarding them to mods that implement the specified API. Github- Mod files, which must end in
mod.js
, that are loaded off disk if present - Nexus Mods where mods can be browsed and downloaded. Eventually, the Vortex client could be used to help install and manage installed mods.
The original files, which for now is just one of the webpacked files 19.js
, have small modifications. One example of such is this:
// granite chat message hook
let allowMessageThrough = true;
if(window.granite.dispatcher) {
allowMessageThrough = window.granite.dispatcher.chat(this.newChatMessage);
}
To find things easier in a 10,000 line minified JS codebase, I marked all sections I modified with granite
. Similarly, to ensure I wouldn’t clash with the game’s own state with random variable names, I used granite
under the global window object to store all my data separately from the game.
Some sections in 19.js
need more, but where possible injected code has as little business logic as possible, focusing on passing along information about what’s happening in the game to the HookDispatcher
.
Of course, the above code pre-supposes all kinds of my data and code is present, for which all of that sits inside the init
function I found and as mentioned in the previous blog post.
A simplified version of that looks like this
- If we haven’t loaded ourselves already, do the below
- Setup a global object space for us to work in and store everything,
window.granite
- Create a function for user-code code to add themselves as a listener
- Asynchronously load
hookdispatcher.js
- Setup various debug, miscellaneous, and some API functions for user-code
With the above done, we can finally escape 19.js
and get into just our own code that is running and separation of concerns is achieved.
What does HookDispatcher
do? Only a few things.
- Finds all files ending in
mod.js
and asynchronously loads them. - Setups up a debug function for user-code to use that prints to the in-game chat for players to easily see, and dumps that to disk in a log file for mod authors to use in debugging.
- Handles incoming events and synchronously applies them to mods that implement the function.
An example, using the injected chat message from above, looks like this after several checks and guards
class HookDispatcher {
...
chat(message) {
...
// Give this chat message to all mods
this.modHooks.forEach(m => {
// If a listener doesn't implement the function, we silently ignore the listener and move on
if(m.chatMessage) {
try {
processed |= m.chatMessage(data);
} catch(err) {
// We ignore all failures within hook handlers. This isolates failing mods from the rest
window.granite.debug(
"ERROR in dispatching chatMessage for mod " + this.#getModName(m) + ". " + err,
window.granite.levels.ERROR
);
}
}
});
}
}
In this handler I allow mods to decide on their own whether a chat message should be allowed through or not. It was an old decision that is being reconsidered. It is presented here to show the messy process of constant iteration modding goes through.
Finally, we get to the most awaited part: user-code that actually does something based on what’s happening in game.
Mods - User-Code - Plugin
Now that we understand how the game client can talk to our mod platform and handle events that get forwarded to mods, what can a mod do after receiving that event? Here is one example mod I made that I use all the time, Coordinate Jumper.
The map of the game is quite large, and all systems are uniquely identifiable by their coordinate position, such as 276, 217
. The game provides no in-game search feature for systems, but users found it was slightly faster to provide a coordinate because players can drag the map around quickly and then match their camera coordinates to system coordinates.
I wanted that to be easier, which first required me figuring out how to control the camera
// granite get camera
if(!window.granite.cameraControl)
window.granite.cameraControl = a;
Note: a
is the camera object, which is only obvious with the surrounding code as context. Minified JS is not fun to read.
This is injected into the original file 19.js
, making it globally accessible. While this did require an additional change for this mod to work, this change means all future mods can also control the camera. Not a bad trade-off.
class CameraChat {
...
chatMessage(message) {
...
// This allows us to support `goto 193:212` and `goto 193 212`
if(message.indexOf(":") !== 0) {
message = message.replace(":", " ");
}
let input = message.split(" ");
window.granite.debug("Parsed input: " + input, window.granite.levels.DEBUG);
let x = parseInt(input[1]);
let y = parseInt(input[2]);
if(x >= 0 && x < 10000 && y >= 0 && y < 10000) {
window.granite.cameraControl.setCameraPosition(x, y, 30);
}
...
}
}
window.granite.addHookListener(new CameraChat());
The above is abridged, but the critical bits are shown and the parts that are hidden are minor. See the Github repo for the full code.
That’s it! Now if I type in the game chat /goto 276 217
my in-game camera is instantly sent there.
Later on I expanded the mod to search by system/sector name pair, so you could do something like /goto serka ougar
to send you to the Serka system in the Ougar sector. Useful if you know the name and sector. Since system names are only sector unique, not globally unique, both are needed.
Results
With this modding plugin-based architecture, I was able to adapt all my mods into this new format, and wow were they much easier to work with after doing this. No need to litter all over the original code base with my code changes. Failures are isolated, mods can dump errors to logs as needed, and I was quickly building up a useful API for others to easily use, without needing to understand the original codebase in-depth.
To put it all together, here is the mod that is needed for all other mods to work: RC Mod API (RCMA). This contains a modified version of the original 19.js
file that users just replace, along with my HookDispatcher
.
Here are the mods I developed using RCMA at the time of this blog post:
- Quick Copy - Copies system coordinates, name, and sector to the clipboard for pasting into Discord (where we all communicate with each other while playing).
- Coordinate Jumper - Easily move around the galaxy
- Income Updater - Exports income data of the player’s empire into a Google Sheet for planning incomes and resources over time.
- Replay Maker - Not yet published, but this is a refactored version of the original mod that produces replays of the game by recording snapshots.
Overall, I am very pleased with this final design and feel it meets all the design goals I set out to solve.