Colyseus: Achieving a True Graceful Shutdown and Managing Room Data

Colyseus is great for handling multiplayer games, but when it comes to shutting things down gracefully, things can get a bit tricky. Rooms can still be created even when the shutdown process starts, making things less “graceful” than you’d like. Don’t worry though—by following a few steps, you can ensure your server shuts down smoothly and takes control over your room data like a pro.

1. Graceful Shutdown: Beyond the Basics

Colyseus claims to offer a graceful shutdown, but in practice, it doesn't wait for rooms to properly close before shutting down the process. This means rooms can still be created even after the shutdown signal is sent. To achieve a true graceful shutdown, you’ll need to add some extra steps.

Key Steps for a Clean Shutdown

  1. Before SIGTERM is sent: Start the shutdown process by calling an API.
  2. Prevent new connections: Stop new rooms from being created while the server is winding down.
  3. Wait for rooms to close: Ensure all active rooms are closed before shutting down the process.

Preparing for Shutdown

First, we need to log the processId of the matchmaker, so we can track the shutdown process:

// Log matchMaker's processId
const filename = 'colyseus.log';
const logPath = `/var/log/${filename}`;

try {
  fs.writeFileSync(logPath, matchMaker.processId);
} catch (e) {
  console.error(
    `Error writing matchMaker.processId: ${matchMaker.processId} to file:`,
    e
  );
}

Now, add an API route to start the shutdown process. This ensures that no new rooms are created during shutdown and allows the server to wait until all rooms are properly closed:

app.get('/termination/:processId', async (req, res) => {
  if (req.params.processId !== matchMaker.processId) {
    console.warn('Invalid processId:', req.params.processId);
    res.status(401).send('Unauthorized');
    return;
  }

  await matchMaker.stats.excludeProcess(matchMaker.processId);
  console.log('Excluded process:', matchMaker.processId);

  await matchMaker.presence.hset(
    'disposingProcess',
    matchMaker.processId,
    'true'
  );

  while (matchMaker.stats.local.roomCount > 0) {
    console.log('Waiting for rooms to close:', matchMaker.stats.local.roomCount);
    await delay(5);
  }

  console.log('All rooms closed');
  await matchMaker.presence.hdel('disposingProcess', matchMaker.processId);
  res.status(200).send('Ready to shutdown');
});

Stopping New Connections

To prevent the server from creating new rooms during the shutdown process, modify the room selection logic to exclude processes marked for shutdown:

config({
  options: {
    selectProcessIdToCreateRoom: this._handleSelectProcessIdToCreateRoom,
  }
});

private _handleSelectProcessIdToCreateRoom = async (roomName: string, options: any) => {
  const disposingProcess = await matchMaker.presence.hgetall('disposingProcess');
  const processes = await matchMaker.stats.fetchAll();

  return processes
    .filter((p) => disposingProcess[p.processId] !== 'true')
    .sort((p1, p2) => (p1.roomCount > p2.roomCount ? 1 : -1))[0];
};

By adding this filter, you ensure that no new rooms are created from processes that are shutting down.

Using Kubernetes for Graceful Shutdowns

If you’re running Colyseus on Kubernetes (K8s), you can use preStop hooks to automate the shutdown process before the server is terminated:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: server
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 1000
      containers:
        - name: colyseus
          ports:
            - containerPort: 2567
          lifecycle:
            preStop:
              exec:
                command:
                  ['/bin/sh', '-c', 'curl localhost:2567/termination/$(cat /var/log/colyseus.log)']

Make sure the terminationGracePeriodSeconds is long enough to allow active rooms to close properly. Adjust this value based on the expected duration of your longest-running rooms.


2. Manually Editing Room Data

Now, let’s talk about manually editing room data. Sometimes, you’ll want to adjust room settings, like expanding the matchmaking range if no suitable match is found. Here’s how you can manage that.

Expanding Room Range

For example, if a player’s matchmaking range is [1200, 1300] and no match is available, you might want to adjust the range to increase the chances of finding a match. This is how you’d typically set up a room:

await matchMaker.createRoom('battle', {
  min: 1200,
  max: 1300,
});

When trying to match a player:

const rooms = await matchMaker.query({
  name: 'battle'
});

const selectedRoom = rooms.find((r) => user.mmr >= r.min && user.mmr < r.max);

if (!selectedRoom) {
  // Handle no match found
}

await matchMaker.reserveSeatFor(selectedRoom);

If no match is found, you can periodically expand the room’s MMR range to improve matchmaking:

const rooms = await matchMaker.query({
  name: 'battle'
});

rooms.forEach((room) => {
  if (room.isOldEnough) {
    room.min -= 50;
    room.max += 50;
  }
});

Adjusting Room Data Across Multiple Servers

When running multiple servers, you’ll need to update the cache across all servers instead of just the local process:

await Promise.all(rooms.map(async (room) => {
  if (room.isOldEnough && room.processId === matchMaker.processId) {
    const cache = await matchMaker.driver.findOne({ roomId: room.roomId });
    cache.min -= 50;
    cache.max += 50;
    await cache.save();
  }
}));

This ensures that room updates are applied across all servers, not just the local instance.


Conclusion

By following these steps, you can ensure a true graceful shutdown of your Colyseus servers, preventing new rooms from being created during shutdown and making sure all active rooms are properly closed. Additionally, you now have control over room data, allowing you to manually adjust matchmaking parameters and improve the player experience.

With a little extra configuration, you can make Colyseus shutdowns and room management work just the way you want them to.