Writing matchmaking for Dota 2014

Hello.



This spring I came across a project in which the guys learned how to run the Dota 2 server of the 2014 version and, accordingly, play on it. I am a big fan of this game, and I could not pass by the unique opportunity to plunge into my childhood.



I plunged very deeply, and it so happened that I wrote a Discord bot, which is responsible for almost all the functionality that is not supported in the old version of the game, namely matchmaking.

Before all the innovations with the bot, the lobby was created manually. We collected 10 responses to a message and manually assembled a server, or hosted a local lobby.







My nature as a programmer could not stand so much manual work, and overnight I sketched the simplest version of the bot, which automatically brought up the server when 10 people were recruited.



I decided to write right away in nodejs, because I don't really like python, and I feel more comfortable in this environment.



This is my first experience of writing a bot for Discord, but it turned out to be very simple. The official npm module discord.js provides a convenient interface for working with messages, collecting reactions, etc.



Disclaimer: All code examples are "up to date", meaning they have gone through several rewriting iterations overnight.



The core of matchmaking is a β€œqueue” where players who want to play are placed and removed when they don't want to or find a game.



This is what the essence of the "player" looks like. Initially, it was just a user id in Discord, but the plans include a launcher / search for a game from the site, but first things first.



export enum Realm {
  DISCORD,
  EXTERNAL,
}

export default class QueuePlayer {
  constructor(public readonly realm: Realm, public readonly id: string) {}

  public is(qp: QueuePlayer): boolean {
    return this.realm === qp.realm && this.id === qp.id;
  }

  static Discord(id: string) {
    return new QueuePlayer(Realm.DISCORD, id);
  }

  static External(id: string) {
    return new QueuePlayer(Realm.EXTERNAL, id);
  }
}


And here is the queue interface. Here, instead of "players", an abstraction in the form of a "group" is used. For a single player, the group consists of himself, and for players in a group, respectively, of all the players in the group.



export default interface IQueue extends EventEmitter {
  inQueue: QueuePlayer[]
  put(uid: Party): boolean;
  remove(uid: Party): boolean;
  removeAll(ids: Party[]): void;

  mode: MatchmakingMode
  roomSize: number;
  clear(): void
}


I decided to use events to exchange context. Suitable for cases - for the event "found a game for 10 people", you can send the desired message to the players in private messages, and execute the main business logic - launch a task to check readiness, prepare the lobby for launch, and so on.



For IOC, I am using InversifyJS. I have a pleasant experience with this library. Fast and easy!



We have several queues on the server - we have added 1x1 modes, normal / rating, and a couple of custom ones. Therefore, there is a singleton RoomService that lies between the user and the game search.



constructor(
    @inject(GameServers) private gameServers: GameServers,
    @inject(MatchStatsService) private stats: MatchStatsService,
    @inject(PartyService) private partyService: PartyService
  ) {
    super();
    this.initQueue(MatchmakingMode.RANKED);
    this.initQueue(MatchmakingMode.UNRANKED);
    this.initQueue(MatchmakingMode.SOLOMID);
    this.initQueue(MatchmakingMode.DIRETIDE);
    this.initQueue(MatchmakingMode.GREEVILING);
    this.partyService.addListener(
      "party-update",
      (event: PartyUpdatedEvent) => {
        this.queues.forEach((q) => {
          if (has(q.queue, (t) => t.is(event.party))) {
            // if queue has this party, we re-add party
            this.leaveQueue(event.qp, q.mode)
            this.enterQueue(event.qp, q.mode)
          }
        });
      }
    );

    this.partyService.addListener(
      "party-removed",
      (event: PartyUpdatedEvent) => {
        this.queues.forEach((q) => {
          if (has(q.queue, (t) => t.is(event.party))) {
            // if queue has this party, we re-add party
            q.remove(event.party)
          }
        });
      }
    );
  }


(Code noodles for an idea of ​​what the processes look like)



Here I initialize a queue for each of the implemented game modes, and also listen to changes in the "groups" in order to correct the queues and avoid some conflicts.



So, I'm great, I inserted pieces of code that have nothing to do with the topic, and now let's move on directly to mastmaking.



Consider a case:



1) The user wants to play.



2) In order to start the search, he uses Gateway = Discord, that is, puts a reaction to the message:







3) This gateway goes to the RoomService, and says "The user from the discord wants to enter the queue, mode: unrated game."



4) RoomService accepts the request of the gateway, and shoves it into the desired user queue (more precisely, the user group).



5) The queue checks if there are enough players to play with each change. If possible, issue an event:



private onRoomFound(players: Party[]) {
    this.emit("room-found", {
      players,
    });
  }


6) RoomService is obviously happy to listen to each queue in anxious anticipation of this event. At the entrance we receive a list of players, form a virtual "room" from them, and, of course, emit an event:



queue.addListener("room-found", (event: RoomFoundEvent) => {
      console.log(
        `Room found mode: [${mode}]. Time to get free room for these guys`
      );
      const room = this.getFreeRoom(mode);
      room.fill(event.players);

      this.onRoomFormed(room);
    });


7) So we got to the "highest" instance - the Bot class . In general, he deals with the connection between gateways (how ridiculous it looks in Russian, I cannot) and the business logic of matchmaking. The bot eavesdrops on the event and orders DiscordGateway to send a readiness check to all users.







8) If someone rejects or does not accept the game in 3 minutes, then we DO NOT return them to the queue. We return everyone else to the queue and wait for 10 people to be recruited again. If all players have accepted the game, then the fun part begins.



Dedicated server configuration



Our games are hosted on VDS with Windows server 2012. Several conclusions can be drawn from this:



  1. There is no docker on it, which hit my heart
  2. We save on rent


The task is to start the process on VDS with VPS on Linux. Wrote a simple server in Flask. Yes, I don't like python, but what can I do - writing this server on it is faster and easier.



It has 3 functions:



  1. Server launch with configuration - map selection, number of players to start the game, and a set of plugins. I will not write about plugins now - this is a separate story with liters of coffee at night mixed with tears and torn hair.
  2. Stopping / restarting the server in case of unsuccessful connections, which we can only handle manually.


Everything is simple here, code examples are even inappropriate. Script for 100 lines



So, when 10 people got together and accepted the game, the server is running and everyone is eager to play, a link to connect to the game comes in private messages.







By clicking on the link, the player connects to the game server, and then that's all. After ~ 25 minutes, the virtual "room" with the players is cleared.



I apologize in advance for the awkwardness of the article, I have not written here for a long time, and there is too much code to highlight important sections. Noodles, in short.



If I see interest in the topic, then there will be a second part - it will contain my torments with plugins for srcds (Source dedicated server), and, probably, a rating system and mini-dotabuff, a site with game statistics.



Few links:



  1. Our site (statistics, leaderboard, small landos and client download)
  2. Discord server



All Articles