DEV Community

Cover image for Creating Multi Agents with NodeJS ( Talk itself into a League of legends Draft with NodeJS )
Allan Felipe Murara
Allan Felipe Murara

Posted on

Creating Multi Agents with NodeJS ( Talk itself into a League of legends Draft with NodeJS )

Introduction

In this tutorial, we'll create an AI Multi Agent environment capable of simulating a League of Legends draft pick process.

What is a ReAct Agent?

A ReAct Agent is a type of AI agent that follows a Reflection-Action cycle. It reflects on the current task, based on available information and actions it can perform, and then decides which action to take or whether to conclude the task.

Agent Structure

Our ReActAgent will have three main states:

  1. THOUGHT (Reflection)
  2. ACTION (Execution)
  3. ANSWER (Response)

Let's Convert this Agent into MultiAgents and the DraftModerator and let the Talk start!!

[1] Initial Setup

First, set up the project and install dependencies:

mkdir lol-draft-simulator
cd lol-draft-simulator
npm init -y
npm install irc @google/generative-ai dotenv
Enter fullscreen mode Exit fullscreen mode

1. Create a .env file at the project's root:

GOOGLE_AI_API_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

FREE ApiKey here

2. ReActAgent.js

The ReAct agent is the class used in each of the players.
Given the ability of react into some situation, they play a fundamental role in building the outcome.

Create ReActAgent.js with the following content:

require("dotenv").config();
const { GoogleGenerativeAI } = require("@google/generative-ai");

class ReActAgent {
  constructor(query, functions, championPool, ircClient, channel) {
    this.query = query;
    this.functions = new Set(functions);
    this.championPool = championPool;
    this.state = "THOUGHT";
    this._history = [];
    this.model = new GoogleGenerativeAI(process.env.GOOGLE_AI_API_KEY).getGenerativeModel({
      model: "gemini-pro",
      temperature: 0.9,
    });
    this.ircClient = ircClient;
    this.channel = channel;
  }

  get history() {
    return this._history;
  }

  pushHistory(value) {
    this._history.push(`\n ${value}`);
  }

  async run(ownComp, enemyComp) {
    this.pushHistory(`**Task: ${this.query} **`);
    this.pushHistory(`Own team composition: ${JSON.stringify(ownComp)}`);
    this.pushHistory(`Enemy team composition: ${JSON.stringify(enemyComp)}`);
    try {
      return await this.step(ownComp, enemyComp);
    } catch (e) {
      console.error("Error in ReActAgent:", e);
      return "Unable to process the draft pick. Please try again.";
    }
  }

  async step(ownComp, enemyComp) {
    console.log(`Current state: ${this.state}`);
    switch (this.state) {
      case "THOUGHT":
        return await this.thought(ownComp, enemyComp);
      case "ACTION":
        return await this.action(ownComp, enemyComp);
      case "ANSWER":
        return await this.answer();
    }
  }

  async promptModel(prompt) {
    try {
      const result = await this.model.generateContent(prompt);
      const response = await result.response;
      return response.text();
    } catch (error) {
      console.error("Error in promptModel:", error);
      return "Error occurred while generating content.";
    }
  }

  async thought(ownComp, enemyComp) {
    const availableFunctions = JSON.stringify(Array.from(this.functions));
    const historyContext = this.history.join("\n");
    const prompt = `You are a League of Legends player in a draft phase. ${this.query}
Whenever there is no opponent composition, it means you are the first.
Your champion pool: ${this.championPool.join(", ")}
Your team composition: ${JSON.stringify(ownComp)}
Enemy team composition: ${JSON.stringify(enemyComp)}
Available actions: ${availableFunctions}
This is a competitive draft so champions can't be repeated.

Based on the current compositions and your champion pool, think about the best pick for your team.
If you are the first, think carefully based on your championPool what to pick in a direction to enabling your team comp.
Then, decide whether to analyze the composition further or make a champion pick.

Context: "${historyContext}"

Respond with your decision in form of a thought and whether you want to "Analyze" or "Pick".`;

    const thought = await this.promptModel(prompt);
    this.pushHistory(`\n **${thought.trim()}**`);
    if (thought.toLowerCase().includes("pick")) {
      this.state = "ANSWER";
    } else {
      this.state = "ACTION";
    }
    return await this.step(ownComp, enemyComp);
  }

  async action(ownComp, enemyComp) {
    const action = "analyzeComposition";
    this.pushHistory(`** Action: ${action} **`);
    const result = await this.analyzeComposition(ownComp, enemyComp);
    this.pushHistory(`** ActionResult: ${result} **`);
    this.state = "THOUGHT";
    return await this.step(ownComp, enemyComp);
  }

  async analyzeComposition(ownComp, enemyComp) {
    const prompt = `Analyze the following team compositions:
Own team: ${JSON.stringify(ownComp)}
Enemy team: ${JSON.stringify(enemyComp)}

Consider team synergies, counter picks, and overall strategy. Suggest potential champions from your pool that would fit well.
Your champion pool: ${this.championPool.join(", ")}

Provide a brief analysis and champion suggestions.`;

    return await this.promptModel(prompt);
  }

  async answer() {
    const historyContext = this.history.join("\n");
    const prompt = `Based on the following context and analysis, choose a champion for the draft.
Your champion pool: ${this.championPool.join(", ")}

Context: ${historyContext}

reflect(>>>FeedForward<( FlushBackL{< from the Context >>  briefly explain into  _justifiedVerify
Respond with: "I pick [ChampionName]" where [ChampionName] is the champion you've decided and the ups and downs _justifiedVerify
`;

    const finalAnswer = await this.promptModel(prompt);
    this.pushHistory(`Answer: ${finalAnswer}`);
    return finalAnswer;
  }

  say(message) {
    this.ircClient.say(this.channel, message);
  }
}

module.exports = ReActAgent;
Enter fullscreen mode Exit fullscreen mode

3. players.js

Players is the file that hold the information about each player pool, tier MMR, and role.

Create players.js to store player data:

const teamBlue = [
  {
    name: "BlueTop",
    rank: "Diamond III",
    role: "Top",
    championPool: [
      "Aatrox", "Camille", "Darius", "Fiora", "Garen", "Gnar", "Irelia", "Jax",
      "Jayce", "Kennen", "Malphite", "Maokai", "Mordekaiser", "Nasus", "Ornn",
      "Poppy", "Renekton", "Riven", "Sett", "Shen", "Sion", "Teemo", "Urgot",
      "Vladimir", "Volibear",
    ],
  },
  {
    name: "BlueJungle",
    rank: "Platinum I",
    role: "Jungle",
    championPool: [
      "Amumu", "Elise", "Evelynn", "Fiddlesticks", "Gragas", "Graves", "Hecarim",
      "Ivern", "Jarvan IV", "Karthus", "Kayn", "Kha'Zix", "Kindred", "Lee Sin",
      "Master Yi", "Nidalee", "Nunu & Willump", "Olaf", "Rammus", "Rek'Sai",
      "Rengar", "Sejuani", "Shaco", "Trundle", "Udyr", "Vi", "Warwick", "Xin Zhao", "Zac",
    ],
  },
  {
    name: "BlueMid",
    rank: "Diamond II",
    role: "Mid",
    championPool: [
      "Ahri", "Akali", "Anivia", "Annie", "Aurelion Sol", "Azir", "Cassiopeia",
      "Corki", "Diana", "Ekko", "Fizz", "Galio", "Kassadin", "Katarina", "LeBlanc",
      "Lissandra", "Lux", "Malzahar", "Neeko", "Orianna", "Qiyana", "Ryze", "Sylas",
      "Syndra", "Talon", "Twisted Fate", "Veigar", "Viktor", "Yasuo", "Zed", "Ziggs", "Zoe",
    ],
  },
  {
    name: "BlueADC",
    rank: "Diamond III",
    role: "ADC",
    championPool: [
      "Aphelios", "Ashe", "Caitlyn", "Draven", "Ezreal", "Jhin", "Jinx", "Kai'Sa",
      "Kalista", "Kog'Maw", "Lucian", "Miss Fortune", "Senna", "Sivir", "Tristana",
      "Twitch", "Varus", "Vayne", "Xayah",
    ],
  },
  {
    name: "BlueSupport",
    rank: "Platinum II",
    role: "Support",
    championPool: [
      "Alistar", "Bard", "Blitzcrank", "Brand", "Braum", "Janna", "Karma", "Leona",
      "Lulu", "Morgana", "Nami", "Nautilus", "Pyke", "Rakan", "Senna", "Sona",
      "Soraka", "Swain", "Tahm Kench", "Taric", "Thresh", "Vel'Koz", "Xerath",
      "Yuumi", "Zilean", "Zyra",
    ],
  },
];

const teamRed = [
  {
    name: "RedTop",
    rank: "Diamond II",
    role: "Top",
    championPool: [
      "Aatrox", "Camille", "Cho'Gath", "Darius", "Dr. Mundo", "Fiora", "Gangplank",
      "Garen", "Gnar", "Illaoi", "Irelia", "Jax", "Jayce", "Kayle", "Kennen", "Kled",
      "Malphite", "Maokai", "Mordekaiser", "Nasus", "Ornn", "Pantheon", "Poppy",
      "Quinn", "Renekton", "Riven", "Rumble", "Ryze", "Sett", "Shen", "Singed",
      "Sion", "Teemo", "Tryndamere", "Urgot", "Vladimir", "Volibear", "Wukong", "Yorick",
    ],
  },
  {
    name: "RedJungle",
    rank: "Diamond I",
    role: "Jungle",
    championPool: [
      "Amumu", "Elise", "Evelynn", "Fiddlesticks", "Gragas", "Graves", "Hecarim",
      "Ivern", "Jarvan IV", "Karthus", "Kayn", "Kha'Zix", "Kindred", "Lee Sin",
      "Master Yi", "Nidalee", "Nocturne", "Nunu & Willump", "Olaf", "Rammus",
      "Rek'Sai", "Rengar", "Sejuani", "Shaco", "Skarner", "Taliyah", "Trundle",
      "Udyr", "Vi", "Warwick", "Xin Zhao", "Zac",
    ],
  },
  {
    name: "RedMid",
    rank: "Master",
    role: "Mid",
    championPool: [
      "Ahri", "Akali", "Anivia", "Annie", "Aurelion Sol", "Azir", "Cassiopeia",
      "Corki", "Diana", "Ekko", "Fizz", "Galio", "Irelia", "Kassadin", "Katarina",
      "LeBlanc", "Lissandra", "Lux", "Malzahar", "Neeko", "Orianna", "Pantheon",
      "Qiyana", "Rumble", "Ryze", "Sylas", "Syndra", "Talon", "Twisted Fate",
      "Veigar", "Viktor", "Xerath", "Yasuo", "Zed", "Ziggs", "Zoe",
    ],
  },
  {
    name: "RedADC",
    rank: "Diamond II",
    role: "ADC",
    championPool: [
      "Aphelios", "Ashe", "Caitlyn", "Draven", "Ezreal", "Jhin", "Jinx", "Kai'Sa",
      "Kalista", "Kog'Maw", "Lucian", "Miss Fortune", "Senna", "Sivir", "Tristana",
      "Twitch", "Varus", "Vayne", "Xayah",
    ],
  },
  {
    name: "RedSupport",
    rank: "Diamond III",
    role: "Support",
    championPool: [
      "Alistar", "Bard", "Blitzcrank", "Brand", "Braum", "Janna", "Karma", "Leona",
      "Lulu", "Morgana", "Nami", "Nautilus", "Pyke", "Rakan", "Senna", "Sona",
      "Soraka", "Swain", "Tahm Kench", "Taric", "Thresh", "Vel'Koz", "Xerath",
      "Yuumi", "Zilean", "Zyra",
    ],
  },
];

module.exports = { teamBlue, teamRed };
Enter fullscreen mode Exit fullscreen mode

4. DraftSimulator.js

DraftSimulator can be seen as the orchestrator of our agents.
It will store a memory and propagate of each decision and announce state changes.

Create DraftSimulator.js to manage the draft simulation:

const irc = require("irc");
const ReActAgent = require("./ReActAgent");
const { teamBlue, teamRed } = require("./players");

class DraftSimulator {
  constructor() {
    console.log("Initializing Draft Simulator...");
    this.channel = "#leagueoflegends-draft";
    this.blueTeam = this.createTeamAgents(teamBlue, "Blue");
    this.redTeam = this.createTeamAgents(teamRed, "Red");
    this.allPlayers = [...this.blueTeam, ...this.redTeam];
    this.draftOrder = this.generateDraftOrder();
    this.currentPickIndex = 0;
    this.blueComp = { Top: null, Jungle: null, Mid: null, ADC: null, Support: null };
    this.redComp = { Top: null, Jungle: null, Mid: null, ADC: null, Support: null };
    this.draftHistory = [];

    console.log("Setting up IRC client...");
    this.moderatorClient = new irc.Client("irc.libera.chat", "DraftModerator", {
      channels: [this.channel],
      port: 6667,
      autoRejoin: true,
      retryCount: 3,
      debug: false,
    });

    this.setupIRCListeners();
    console.log("Draft Simulator initialized.");
  }

  createTeamAgents(teamData, teamColor) {
    console.log(`Creating ${teamColor} team agents...`);
    return teamData.map((player) => {
      const query = `As ${player.name}, a ${player.rank} ${player.role} player for team ${teamColor}, choose a champion for the current draft.`;
      const functions = [
        ["analyzeComposition", "params: ownComp, enemyComp", "Analyze the current team compositions"],
      ];
      const ircClient = new irc.Client("irc.libera.chat", player.name, {
        channels: [this.channel],
        port: 6667,
        autoRejoin: true,
        retryCount: 3,
        debug: false,
      });
      console.log(`Created agent for ${player.name} (${player.role})`);
      return {
        role: player.role,
        agent: new ReActAgent(query, functions, player.championPool, ircClient, this.channel),
      };
    });
  }

  generateDraftOrder() {
    console.log("Generating draft order...");
    const order = [];
    const blueRoles = ["Top", "Jungle", "Mid", "ADC", "Support"];
    const redRoles = ["Top", "Jungle", "Mid", "ADC", "Support"];

    const pickForTeam = (team, count) => {
      const roles = team === "Blue" ? blueRoles : redRoles;
      for (let i = 0; i < count; i++) {
        if (roles.length > 0) {
          const role = this.getRandomRole(roles);
          order.push({ team, role });
        }
      }
    };

    pickForTeam("Blue", 1);
    pickForTeam("Red", 2);
    pickForTeam("Blue", 2);
    pickForTeam("Red", 2);
    pickForTeam("Blue", 2);
    pickForTeam("Red", 1);

    console.log("Draft order generated:", order);
    return order;
  }

  getRandomRole(roles) {
    const index = Math.floor(Math.random() * roles.length);
    const role = roles[index];
    roles.splice(index, 1);
    return role;
  }

  setupIRCListeners() {
    console.log("Setting up IRC listeners...");
    this.moderatorClient.addListener("error", this.handleIrcError.bind(this));
    this.moderatorClient.addListener("registered", this.handleRegistered.bind(this));
    this.moderatorClient.addListener("join", this.handleJoin.bind(this));
    this.moderatorClient.addListener("message" + this.channel, this.handleMessage.bind(this));
  }

  handleIrcError(message) {
    console.error("IRC Error:", message);
  }

  handleRegistered(message) {
    console.log("Registered with IRC server:", message);
  }

  handleJoin(channel, nick, message) {
    console.log(`Joined ${channel} as ${nick}`);
    if (channel === this.channel && nick === this.moderatorClient.nick) {
      console.log("Moderator joined the correct channel, starting draft...");
      setTimeout(() => this.startDraft(), 1000);
    }
  }

  handleMessage(from, message) {
    console.log(`Message received in ${this.channel}: ${from} => ${message}`);
  }

  async startDraft() {
    console.log("Starting draft...");
    this.moderatorClient.say(this.channel, "Draft is starting!");
    setTimeout(() => this.nextPick(), 1000);
  }

  async nextPick() {
    console.log(`Processing pick ${this.currentPickIndex + 1} of ${this.draftOrder.length}`);
    if (this.currentPickIndex >= this.draftOrder.length) {
      this.endDraft();
      return;
    }

    const currentPick = this.draftOrder[this.currentPickIndex];
    const currentTeam = currentPick.team;
    const currentRole = currentPick.role;
    const teamPlayers = currentTeam === "Blue" ? this.blueTeam : this.redTeam;

    console.log(`Current pick: ${currentTeam} team, ${currentRole} role`);
    this.announceDraftState();

    const currentPlayer = teamPlayers.find((p) => p.role === currentRole);

    if (!currentPlayer) {
      console.error(`No player found for role ${currentRole} in ${currentTeam} team`);
      this.moderatorClient.say(this.channel, `Error: No player found for ${currentRole} in ${currentTeam} team. Skipping this pick.`);
      this.currentPickIndex++;
      setTimeout(() => this.nextPick(), 2000);
      return;
    }

    const ownComp = currentTeam === "Blue" ? this.blueComp : this.redComp;
    const enemyComp = currentTeam === "Blue" ? this.redComp : this.blueComp;

    try {
      console.log(`Requesting pick from ${currentPlayer.agent.query.split(",")[0]}...`);
      const result = await currentPlayer.agent.run(ownComp, enemyComp);
      console.log(`Received result from agent: ${result}`);
      const champion = this.extractChampionFromResult(result);

      if (champion === "Unknown Champion") {
        throw new Error("Failed to extract a valid champion from the result");
      }

      console.log(`Extracted champion: ${champion}`);
      this.processPick(currentTeam, currentRole, champion);
      currentPlayer.agent.say(`I pick ${champion} for ${currentRole}. Justified with: ${result}`);

      this.moderatorClient.say(this.channel, `${currentPlayer.agent.query.split(",")[0]} has picked ${champion} for ${currentRole}.`);

      this.currentPickIndex++;
      setTimeout(() => this.nextPick(), 7000);
    } catch (error) {
      console.error("Error during pick:", error);
      this.moderatorClient.say(this.channel, `An error occurred during the pick. ${currentPlayer.agent.query.split(",")[0]} will be assigned a random champion from their pool.`);
      const randomChampion = this.getRandomChampion(currentPlayer.agent.championPool);
      console.log(`Assigning random champion: ${randomChampion}`);
      this.processPick(currentTeam, currentRole, randomChampion);
      this.moderatorClient.say(this.channel, `${currentPlayer.agent.query.split(",")[0]} has been assigned ${randomChampion} for ${currentRole}.`);
      this.currentPickIndex++;
      setTimeout(() => this.nextPick(), 7000);
    }
  }

  extractChampionFromResult(result) {
    console.log("Extracting champion from result...");
    let match = result.match(/I pick (\w+)/i);
    if (match) return match[1];

    match = result.match(/\*\*(\w+)\*\*/);
    if (match) return match[1];

    const words = result.split(/\s+/);
    for (const word of words) {
      const champion = word.replace(/[^a-zA-Z']/g, "");
      if (this.isValidChampion(champion)) {
        return champion;
      }
    }

    console.log("Unable to extract champion from result.");
    return "Unknown Champion";
  }

  isValidChampion(champion) {
    const allChampions = new Set([
      ...teamBlue.flatMap((player) => player.championPool),
      ...teamRed.flatMap((player) => player.championPool),
    ]);
    return allChampions.has(champion);
  }

  getRandomChampion(championPool) {
    return championPool[Math.floor(Math.random() * championPool.length)];
  }

  announceDraftState() {
    console.log("Announcing current draft state...");
    const blueTeamComp = Object.entries(this.blueComp)
      .map(([role, champion]) => `${role}: ${champion || "Not picked"}`)
      .join(", ");
    const redTeamComp = Object.entries(this.redComp)
      .map(([role, champion]) => `${role}: ${champion || "Not picked"}`)
      .join(", ");

    const currentPick = this.draftOrder[this.currentPickIndex];

    this.moderatorClient.say(
      this.channel,
      `Current Draft State:
      Blue Team: ${blueTeamComp}
      Red Team: ${redTeamComp}
      It's ${currentPick.team} team's turn to pick for ${currentPick.role}.`
    );
  }

  processPick(team, role, champion) {
    console.log(`Processing pick: ${team} ${role} - ${champion}`);
    if (team === "Blue") {
      this.blueComp[role] = champion;
    } else {
      this.redComp[role] = champion;
    }
    this.draftHistory.push(`${team} ${role}: ${champion}`);
  }

  endDraft() {
    console.log("Draft completed!");
    console.log("Blue Team Composition:", this.blueComp);
    console.log("Red Team Composition:", this.redComp);
    this.moderatorClient.say(this.channel, "Draft completed! Final compositions:");
    setTimeout(() => {
      this.moderatorClient.say(this.channel, `Blue Team: ${JSON.stringify(this.blueComp)}`);
    }, 1000);
    setTimeout(() => {
      this.moderatorClient.say(this.channel, `Red Team: ${JSON.stringify(this.redComp)}`);
    }, 2000);

    setTimeout(() => {
      console.log("Disconnecting all clients...");
      this.allPlayers.forEach((player) => player.agent.ircClient.disconnect());
      this.moderatorClient.disconnect("Draft completed", () => {
        console.log("Disconnected from IRC");
        process.exit(0);
      });
    }, 5000);
  }
}

module.exports = DraftSimulator;
Enter fullscreen mode Exit fullscreen mode

5. index.js:

Create index.js in the root project

require("dotenv").config();
const DraftSimulator = require("./DraftSimulator");

async function main() {
  try {
    const simulator = new DraftSimulator();
    // The draft will start automatically when connected to IRC
  } catch (error) {
    console.error("Error in main:", error);
  }
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Running the Simulation

To run the simulation, execute:

node index.js
Enter fullscreen mode Exit fullscreen mode

This will start the draft simulation, and you'll see the progress in the console and in the specified IRC channel.
Hope you have fun and enjoy simulating drafts on league of legends with AI controlled agents.

How the Draft Simulation Works

  1. The DraftSimulator creates AI agents for each player on both teams.
  2. It generates a random draft order.
  3. For each pick:
    • The current player's agent enters the THOUGHT state to analyze the current compositions.
    • It may enter the ACTION state to perform further analysis.
    • Finally, it enters the ANSWER state to make a champion pick.
  4. The pick is announced through IRC, and the process continues until all positions are filled.

Execution Flow Example

  1. The simulation starts when the IRC moderator joins the channel.
  2. For each pick in the draft order:
    • The current player's AI agent is activated.
    • The agent thinks about the current team compositions.
    • It may analyze the compositions further.
    • The agent makes a champion pick.
    • The pick is announced in the IRC channel.
  3. This process repeats until all positions are filled for both teams.
  4. The final team compositions are announced, and the simulation ends.

Final Considerations

  • The modular structure allows for easy modifications to the draft process or AI decision-making.
  • Error handling is implemented to deal with unexpected issues during the draft.
  • The simulation uses IRC for communication, making it possible to observe the draft in real-time using an IRC client.
  • The temperature setting (0.9) in the AI model allows for some creativity in picks while maintaining consistency.

Top comments (0)