DEV Community

Ian
Ian

Posted on

Building an Idle game Part 2 - The code

If you missed Part 1 you can find that over here. It is recommended to read that first to understand the theory behind some of the choices made in this part.

So we have now covered the theory of how the tick system will work for skills, now it is time to actually implement it. I'm going to go through the core parts, the UI will be in the repository however I won't show that in this part.

The entry point

Our entry point is server.js so let's check out what is in there

const cluster = require('cluster');

if(cluster.isMaster)
{
    require("./cluster/master");
} else {
    require("./cluster/child");
}
Enter fullscreen mode Exit fullscreen mode

For now all it needs to do is handle clustering, we are building this in now as the project I am working on uses it, you are welcome to remove it, though you may need to tweak some things in cluster/master.js and cluster/child.js.

Cluster master process

cluster/master.js contains a bit more logic but it's pretty boilerplate as far as clustering seems to go, we fork it depending on the cores and setup a message handler

const cluster = require('cluster');
const cores = require('os').cpus().length;

console.log("Master process running");

for (let i = 0; i < cores; i++) {
    cluster.fork();
}

function messageHandler(message) {
    switch(message.cmd) {
        case 'disconnect user':
            return eachWorker((worker) => {
                worker.send(message);
            });
    }
}
function eachWorker(callback) {
    for (const id in cluster.workers) {
        callback(cluster.workers[id]);
    }
}

for (const id in cluster.workers) {
    cluster.workers[id].on('message', messageHandler);
}

cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
});
Enter fullscreen mode Exit fullscreen mode

Cluster child process

child.js is quite a clutter at the moment so we will dissect it piece by piece.

let tasks = {};

const taskFiles = fs.readdirSync('./skills').filter(
    file => file.endsWith('.js')
);

for (const file of taskFiles) {
    const task = require(`../skills/${file}`);
    tasks[task.name] = task;
    console.log(`Loaded task ${task.name}`)
}
Enter fullscreen mode Exit fullscreen mode

Here we are loading in all our skills so they can be used when needed, no need to manually find them, so whatever files ending in .js that are in /skills will get loaded up.

The next part of child.js is the socket, so let's jump into what happens when the login event is emitted from the client.

First we disconnect the user from all the other clusters so that no duplicate sessions are held for things like timers. We send an event to the master process, which in turn sends it to all workers.

        process.send({
            cmd: 'disconnect user',
            data: {
                user: credentials.username
            }
        });
Enter fullscreen mode Exit fullscreen mode

Next is adding the user to the online array and actually loading the user in. We also join a private room so that we can emit events into that room for that user later on

let username = credentials.username;

usersOnline.push(username);

socket.join(`private user ${username}`);

user = new User(username, socket, client, tasks);
await user.load();
Enter fullscreen mode Exit fullscreen mode

Now that we have loaded the user we need to let the client know about it, so we send the activeAction along with the username

socket.emit('login', {
    username: username,
    currentTask: user.data.activeAction
});
Enter fullscreen mode Exit fullscreen mode

The final part of login is emitting the config which contains information such as locations for resources, items, monsters. We cache this in local storage on the client side and in future we will also include versioning. There are multiple benefits of this.

  • You don't need a separate config for the front and backend, just the backend
  • You can change what config is sent at any point just by pushing a change to the database
  • It prevents data mining, if you have secret items or achievements which should only be cached if the user has found them, this prevents spoilers for when no one have met an achievement or found an item
  • Save bandwidth, no need to download a config file everytime (Our current solution does this)
socket.emit('config', config);

// Send the signal to end the loading screen and now we're ready to play
socket.emit('ready to play');
Enter fullscreen mode Exit fullscreen mode

We listen to three other events, start task, stop task and disconnect, these just call a method on the User.

socket.on('start task', (task) => {
    user.startTask(task);
});

socket.on('stop task', () => {
    user.stopTask();
});

socket.on('disconnect', () => {
    if(user instanceof User)
    {
        user.clearTimers();
        delete usersOnline[id];
        user = null;
    }
});
Enter fullscreen mode Exit fullscreen mode

The last bit I want to explain of this file is subscribing to redis and handling disconnects. Redis is a key part of the application, it's sort of the glue that holds a bunch of things together such as pub/sub

So we first create the client for subscriptions and subscribe to stream. Every message received on this stream will be parsed and sent through socket.io, this is handy for external applications sending events inwards, handling cross server communication and, handling our own internal events

let sub = redis.createClient();

sub.subscribe('stream');

sub.on('message', function (channel, message) {
    let m = JSON.parse(message);

    io.to(m.channel).emit(m.event, m.data)
});

process.on('message', (message) => {
    if(message.cmd === 'disconnect user') {
        if(users.includes(message.data.username)) {
            users[message.data.username].methods.clearTimers();
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

The tick system!

It's finally time to explore the tick system, probably the most exciting part of this article. All our logic is held in the User class, for better or worse.

The class is initialised when a user connects as you can read above. I have changed, refactored, changed, deleted and changed some more, so I do believe there is some dead or redundant code. I'll be going through it again later on as we expand it to refactor and optimise.

startTask() is the entry point to starting a task, this is always called when we start a task, be it from the client, or internally.

startTask(task) {
    this.stopTask();
    this.data.activeAction = task[0];
    this.data.activeSkill = task[1];
    this.currentAction = this.config[task[1]][task[0]];
    this.save();
    this.executeTask();
}
Enter fullscreen mode Exit fullscreen mode

We attempt to stop any running tasks, we then set the activeAction and activeSkill then proceed to save() to Redis, then execute the task.

executeTask() is where most of the logic is handled in regards to tasks and timing.

    executeTask() {
        let activeAction = this.data.activeAction;
        let activeSkill = this.data.activeSkill;

        if(!this.config[activeSkill][activeAction])
            return;

        let currentAction = this.config[activeSkill][activeAction];

        // Check if task exists
        if (this.tasks.hasOwnProperty(activeSkill)) {
            this.clearTimer('resource');

            let timer = this.getTaskTimer();

            this.socket.emit('startProgressBar', {activeAction, timer});

            this.timers['resource'] = setTimeout(() => {
                this.tasks[activeSkill].execute(
                    currentAction,
                    this.socket,
                    this.data
                );
                this.executeTask()
            }, timer)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Basic validation is the first step to make here then calculate the timer, getTaskTimer() simply returns a number from a config file. For this purpose think of it as 5000. So after five seconds we execute the task and then call the same function so that the progress bar gets started again.

I found it a little hard to wrap my head around, until I actually built it and it all made sense. I toyed with setInterval but ultimately found that setTimeout fit our needs much better given we want to be able to change timers on the fly, imagine an enchantment that gives a 30% to speed up the next action by 50%, you need to be able to do this easily and setInterval I found didn't work as well.

That concludes the tick system at least, it's pretty basic once it has been split up. We do have some other methods for clearing timers and setting timers.

Overall it was fun to make, the frontend was much harder than the logic on the backend mainly due to dealing with animations. Most of all I got to experiment on all aspects of this from how the tick system would work to figuring out how to get the frontend syncing up correctly.

I will be continuing the series and implementing SQL next along with authentication, I hope to have one part out a week, though some pieces may take two or three weeks depending on what is happening during the week

Top comments (0)