Ríobhca's Thoughts

Detecting Deranking Bots in League of Legends with Go

Introduction

For my first post ever on this blog, I'm going to write about a small project I did. First, some personal information: I play League of Legends, a game with an infamously...friendly community. I used to play it a lot when I was younger (with all the free time being a student entails...) but stopped after I graduated since I didn't really have the time anymore.

Enter 2020. I'd just moved back home with my parents after a rough breakup, with the start of COVID, and I went back to games I'd enjoyed in the past. One of those games happened to be League. During the process of getting back into League, I made some new friends who also played. One of these friends was particularly low ranked, and he made a complaint that fascinated me: there were bots in his games all the time. This might have been an exaggeration on his part, but looking into the subreddit for League, /r/leagueoflegends, others also faced the same problems, as the below posts show:

The New Player Experience (and Iron / Bronze) are Being Absolutely Ruined by the Rampant Bots

Ayo something needs to be done about the rampant bots in ranked.

If riot is making anonymous champ select a thing, something has to be done about bots in low elo

What can I do about the trolls/bots in low elo?

Why isn't Riot addressing the invasion of bots in ranked games?

These above posts are just a small selection of the complaints that people have around botting, and it wasn't just my friend complaining for the sake of complaining. Which leads on nicely to my next question....

Why?

So why do people make these bots? The main appeal of the ranked mode in League is to get better and climb the ranked ladder (most people want to get Gold since you used to get a special skin for reaching that rank, but Riot changed it so you get it regardless of rank if you played enough games). This bots, by virtue of their presence and them not being very useful, are made to lose games. This is anathema to why most people play ranked - why queue up to lose? Why try and get to the rank of Iron IV (the lowest rank possible) when you could try and reach Gold, or even higher like the Apex tiers of Masters, Grandmasters and Challenger?

The reason why these bots exist is that people use them to intentionally derank accounts and sell them to others. These accounts can often sell for a lot of money, sometimes upward of 100ifitshandderanked.(whichisoutsidethescopeofthisblog)Comparethistotheregularpriceoffreshlevel30accounts(akaa"freshie"),whichcanbesoldforascheapas2.

We have to account that most people exist in the ranks of Bronze and Silver - the lowest rank which these bots aim to get, Iron, contains a whopping 7.2% of the population worldwide. Compare this to the combined population of Bronze and Silver, with 56% of players in those ranks. (Including yours truly! Silver stucker for life.) Even then, most people in Iron are on the brink of getting to Bronze. Iron IV, the lowest possible rank in League, contains only 0.32% of the playerbase. People are genuinely curious to what life in the lowest ranks is like, and League content creators have done series in the past spectating Iron games, such as RossBoomsocks. There's even a Twitch stream called SaltyTeemo which provides 24/7 coverage of Iron games across all regions. People might purchase an Iron account just to see if it's as mad as the League content scene makes it.

There is also the fact that it's very hard to even get to Iron in the first place - most accounts when they start ranked end up in Bronze at the lowest. It means you have to lose a lot of games in order to get there. Why not automate it and save yourself the hassle?

Some buy these accounts to "smurf" on lower ranked players. (If you're unaware of what a smurf is, it's often a higher ranked player making an alt account to play in lower ranks, often stomping the competition because of the skill difference). Some of these account sellers even state this in their listings, for example Unranked Smurfs:

People often complain about ELO hell, their difficulties with teammates, and how unreliable they can be. Well, let me assure you, whatever you have seen unless you have had an Iron experience, you don't know anything yet. By starting fresh on an account in Iron, you are choosing to surround yourself with the absolute worst players in the entire game, the bottom of the barrel 1% - so incompetent you can't even begin to describe it. Playing in Iron is an experience.

What we are providing you is a unique experience. You will be able to play what you want, wherever you want, and perform so far above average that every single game will be a cakewalk.

Undoubtedly the best place to start practising new champions or roles is where you can rest assured your opponents will not be able to hold a candle to anything you do. These Iron games are comedy-packed and more fun than you've probably experienced on League of Legends in months!

League content creators who have bought such Iron accounts have stated their motivations, such as in this video by RatIRL:

I'm kinda in the League zone... I love stomping Iron players... it's the biggest ego boost in existence! And then you're gonna go on your main account...and you're gonna just stomp because you're in the zone and you think you're unstoppable...

People who buy these Iron accounts and make content stomping on Iron/Bronze players are usually negatively perceived by the League community at large. I asked a few high elo streamers that I watch, namely No Arm Whatley (he has both his arms btw) and Urpog what their opinions were on this issue.

Whatley said:

...however it's likely pretty damaging to the lower ranked player experience and the ecosystem as a whole. I think Smurf Queue actually did a good job to counter this issue.

(Editors note: Smurf Queue was a system that existed until recently that Riot implemented that grouped players on new accounts, and some accounts from returning players, that they detected were not new players. Smurf Queue can be summed up as "abandon all hope, ye who enter here", filled with players with 200 banned accounts spamming slurs and telling you to "keep yourself safe" in new and interesting ways. Riot removed this and added a new system that groups these new accounts into queues that reflect their hidden rating, so a new account with a rating of Diamond will be grouped with real Diamond players rather than other smurfs)

And Urpog:

"People want to buy Iron accounts because they want to feel better than people.... it's very cringe"

Solving this botting problem

So we've covered the motivations on why people make these deranking bots - there's obviously a demand for them, and it's a real problem to lower ranked players who, through no fault of their own, have to deal with these bots in their team and maybe even face the same account, now owned by someone who gets their kicks by stomping them. It's not fair for them. Even through some in the community would forget this, League is a game at heart. Just because someone is bad at the game, doesn't mean they also can't enjoy it. How do we solve this?

The good thing about these bots is that they share a lot of characteristics - there seems to be a template to them. A lot of these bots pick champions that can just sit back, shield and heal - the bare minimum to be not considered AFK. They often queue in the support role, since you can't really get away with that in other roles in the game. A recent development in these bots are those that only queue in the jungle role and AFK clear jungle camps, picking champions that can AOE damage camps.

This means we can make a list of the most common champions to be botted:

...which is a good starting point, but we need MORE. I also thought of looking at the winrates of the potential botted accounts, since if they're made to lose games the winrate will reflect that. I do want to point out that this could catch an unlucky player who has a low winrate. I also wanted to collect some stats that "normal" players would do but these bots are incapable of, such as:

Lucky for us, this is all provided in Riot's API!

I originally had a small Python script that pulled data from Riot's API for players that were suspected of being bots that polled their match history for certain characteristics that these bots had. This Python script was manky and certainly not fit for public consumption. In a past life, I used to be a Go developer, and while I have a love-hate relationship with Go I wanted to improve my skills in it. So I ported it over to Go and tidied it up a little bit. I also wanted to see what the machine learning libraries were like in Go - it's not really known for it's machine learning capabilities (that's more Python's thing). The plan was to see how GoLearn was like, and if I couldn't do what I wanted to do I'd switch back to Python.

Starting the work in Go, I found that there was a wrapper for Riot's API called Golio, which made my work a lot easier than it had to be. I've written wrappers for APIs before, but it's always nice to do less work :)

Golio provides several Go representations of what Riot returns from their API. Looking at the Riot API, we will need to request data from the:

Golio provides representation for the endpoints we need to get data from, as seen here:

// GetByPUUID returns the summoner with the given PUUID
func (s *SummonerClient) GetByPUUID(puuid string) (*Summoner, error) {
	return s.getBy(identificationPUUID, puuid, s.logger().WithField("method", "GetByPUUID"))
}
// Get returns a match specified by its ID
func (m *MatchClient) Get(id string) (*Match, error) {
	logger := m.logger().WithField("method", "Get")
	c := *m.c                                          // copy client
	c.Region = api.Region(api.RegionToRoute[c.Region]) // Match v5 uses a route instead of a region
	var match *Match
	if err := c.GetInto(fmt.Sprintf(endpointGetMatch, id), &match); err != nil {
		logger.Debug(err)
		return nil, err
	}
	return match, nil
}

// List returns  a list of match ids by puuid
func (m *MatchClient) List(puuid string, start, count int, options ...*MatchListOptions) (
	[]string, error) {
	logger := m.logger().WithField("method", "List")
	c := *m.c                                          // copy client
	c.Region = api.Region(api.RegionToRoute[c.Region]) // Match v5 uses a route instead of a region
	var matches []string
	endpoint := fmt.Sprintf(endpointGetMatchIDs, puuid, start, count)
	if len(options) != 0 {
		endpoint += options[0].buildParam()
	}
	if err := c.GetInto(endpoint, &matches); err != nil {
		logger.Debug(err)
		return nil, err
	}
	return matches, nil
}

// ListStream returns all matches played on this account as a stream, requesting new until there are no
// more new games
func (m *MatchClient) ListStream(puuid string, options ...*MatchListOptions) <-chan MatchStreamValue {
	logger := m.logger().WithField("method", "ListStream")
	cMatches := make(chan MatchStreamValue, 100)

	// Copy the input options to prevent caller modification while streaming
	opts := make([]*MatchListOptions, 0)
	for _, o := range options {
		// Copy the value in case the caller modifies it after we return
		queue := *options[0].Queue
		// Shallow copy the other values
		newOpt := *o
		newOpt.Queue = &queue
		opts = append(opts, &newOpt)
	}
	if len(options) != 0 && options[0].Queue != nil {
		// Copy the value in case the caller modifies it after we return
		queue := *options[0].Queue
		options[0].Queue = &queue
	}
	go func() {
		defer close(cMatches)
		start := 0
		for {
			matches, err := m.List(puuid, start, 100, opts...)
			if err != nil {
				logger.Debug(err)
				cMatches <- MatchStreamValue{Error: err}
				return
			}
			for _, match := range matches {
				cMatches <- MatchStreamValue{MatchID: match}
			}
			if len(matches) < 100 {
				return
			}
			start += 100
		}
	}()
	return cMatches
}

I was torn between using either the List() or the ListStream() functions, but in the end I decided to go with ListStream as it constantly polls the Riot API using a go func and channels (which I do not use enough of) to get all the match history for a particular user. Because of how channels work in Go (here is a nice stack overflow answer that will explain it a lot better than I ever can!) we have a nice queue of all the matches the player has played in a quick-ish way. We still have to contend with Riot rate limiting which makes it a little bit slow, but I can live with that :)

There's also representations of both the Summoner (aka the player) and Match that the Riot APIs return, meaning I have to do even less work.

// Match contains information about a match
type Match struct {
	// Match metadata
	Metadata *MatchMetadata `json:"metadata"`
	// Match info
	Info *MatchInfo `json:"info"`
}

// MatchMetadata contains metadata for a specific match
type MatchMetadata struct {
	// Match data version
	DataVersion string `json:"dataVersion"`
	// Match ID
	MatchID string `json:"matchId"`
	// List of participant PUUIDs
	Participants []string `json:"participants"`
}

// MatchInfo contains the data for a specific match
type MatchInfo struct {
	// Unix timestamp for when the game is created on the game server (i.e., the loading screen).
	GameCreation int64 `json:"gameCreation"`
	// Prior to patch 11.20, this field returns the game length in milliseconds calculated
	// from gameEndTimestamp - gameStartTimestamp. Post patch 11.20, this field returns the max
	// timePlayed of any participant in the game in seconds, which makes the behavior of this
	// field consistent with that of match-v4. The best way to handling the change in this field
	// is to treat the value as milliseconds if the gameEndTimestamp field isn't in the response
	// and to treat the value as seconds if gameEndTimestamp is in the response.
	GameDuration int `json:"gameDuration"`
	// Unix timestamp for when match ends on the game server. This timestamp can occasionally
	// be significantly longer than when the match "ends". The most reliable way of determining
	// the timestamp for the end of the match would be to add the max time played of any
	// participant to the gameStartTimestamp. This field was added to match-v5 in patch 11.20 on Oct 5th, 2021.
	GameEndTimestamp int64 `json:"gameEndTimestamp"`
	GameID           int64 `json:"gameId"`
	// Please refer to the Game Constants documentation.
	GameMode string `json:"gameMode"`
	GameName string `json:"gameName"`
	// Unix timestamp for when match starts on the game server.
	GameStartTimestamp int64 `json:"gameStartTimestamp"`
	// Please refer to the Game Constants documentation.
	GameType string `json:"gameType"`
	// The first two parts can be used to determine the patch a game was played on.
	GameVersion string `json:"gameVersion"`
	// Please refer to the Game Constants documentation.
	MapID int `json:"mapId"`
	// Participant information.
	Participants []*Participant `json:"participants"`
	// Platform where the match was played.
	PlatformID string `json:"platformId"`
	// Please refer to the Game Constants documentation.
	QueueID int `json:"queueId"`
	// Team information.
	Teams []*Team `json:"teams"`
	// Tournament code used to generate the match. This field was added to match-v5 in patch 11.13 on June 23rd, 2021.
	TournamentCode string `json:"tournamentCode"`
}

// Participant hold information for a participant of a match
type Participant struct {
	Assists         int `json:"assists"`
	BaronKills      int `json:"baronKills"`
	BountyLevel     int `json:"bountyLevel"`
	ChampExperience int `json:"champExperience"`
	ChampLevel      int `json:"champLevel"`
	// Prior to patch 11.4, on Feb 18th, 2021, this field returned invalid championIds.
	// We recommend determining the champion based on the championName field for matches played prior to patch 11.4.
	ChampionID   int    `json:"championId"`
	ChampionName string `json:"championName"`
	// This field is currently only utilized for Kayn's transformations.
	// (Legal values: 0 - None, 1 - Slayer, 2 - Assassin)
	ChampionTransform         int  `json:"championTransform"`
	ConsumablesPurchased      int  `json:"consumablesPurchased"`
	DamageDealtToBuildings    int  `json:"damageDealtToBuildings"`
	DamageDealtToObjectives   int  `json:"damageDealtToObjectives"`
	DamageDealtToTurrets      int  `json:"damageDealtToTurrets"`
	DamageSelfMitigated       int  `json:"damageSelfMitigated"`
	Deaths                    int  `json:"deaths"`
	DetectorWardsPlaced       int  `json:"detectorWardsPlaced"`
	DoubleKills               int  `json:"doubleKills"`
	DragonKills               int  `json:"dragonKills"`
	FirstBloodAssist          bool `json:"firstBloodAssist"`
	FirstBloodKill            bool `json:"firstBloodKill"`
	FirstTowerAssist          bool `json:"firstTowerAssist"`
	FirstTowerKill            bool `json:"firstTowerKill"`
	GameEndedInEarlySurrender bool `json:"gameEndedInEarlySurrender"`
	GameEndedInSurrender      bool `json:"gameEndedInSurrender"`
	GoldEarned                int  `json:"goldEarned"`
	GoldSpent                 int  `json:"goldSpent"`
	// Both individualPosition and teamPosition are computed by the game server and are
	// different versions of the most likely position played by a player. The individualPosition
	// is the best guess for which position the player actually played in isolation of
	// anything else. The teamPosition is the best guess for which position the player
	// actually played if we add the constraint that each team must have one top player, one
	// jungle, one middle, etc. Generally the recommendation is to use the teamPosition field
	// over the individualPosition field.
	IndividualPosition             string            `json:"individualPosition"`
	InhibitorKills                 int               `json:"inhibitorKills"`
	InhibitorTakedowns             int               `json:"inhibitorTakedowns"`
	InhibitorsLost                 int               `json:"inhibitorsLost"`
	Item0                          int               `json:"item0"`
	Item1                          int               `json:"item1"`
	Item2                          int               `json:"item2"`
	Item3                          int               `json:"item3"`
	Item4                          int               `json:"item4"`
	Item5                          int               `json:"item5"`
	Item6                          int               `json:"item6"`
	ItemsPurchased                 int               `json:"itemsPurchased"`
	KillingSprees                  int               `json:"killingSprees"`
	Kills                          int               `json:"kills"`
	Lane                           string            `json:"lane"`
	LargestCriticalStrike          int               `json:"largestCriticalStrike"`
	LargestKillingSpree            int               `json:"largestKillingSpree"`
	LargestMultiKill               int               `json:"largestMultiKill"`
	LongestTimeSpentLiving         int               `json:"longestTimeSpentLiving"`
	MagicDamageDealt               int               `json:"magicDamageDealt"`
	MagicDamageDealtToChampions    int               `json:"magicDamageDealtToChampions"`
	MagicDamageTaken               int               `json:"magicDamageTaken"`
	NeutralMinionsKilled           int               `json:"neutralMinionsKilled"`
	NexusKills                     int               `json:"nexusKills"`
	NexusLost                      int               `json:"nexusLost"`
	NexusTakedowns                 int               `json:"nexusTakedowns"`
	ObjectivesStolen               int               `json:"objectivesStolen"`
	ObjectivesStolenAssists        int               `json:"objectivesStolenAssists"`
	ParticipantID                  int               `json:"participantId"`
	PentaKills                     int               `json:"pentaKills"`
	Perks                          *ParticipantPerks `json:"perks"`
	PhysicalDamageDealt            int               `json:"physicalDamageDealt"`
	PhysicalDamageDealtToChampions int               `json:"physicalDamageDealtToChampions"`
	PhysicalDamageTaken            int               `json:"physicalDamageTaken"`
	ProfileIcon                    int               `json:"profileIcon"`
	PUUID                          string            `json:"puuid"`
	QuadraKills                    int               `json:"quadraKills"`
	RiotIDName                     string            `json:"riotIdName"`
	RiotIDTagline                  string            `json:"riotIdTagline"`
	Role                           string            `json:"role"`
	SightWardsBoughtInGame         int               `json:"sightWardsBoughtInGame"`
	Spell1Casts                    int               `json:"spell1Casts"`
	Spell2Casts                    int               `json:"spell2Casts"`
	Spell3Casts                    int               `json:"spell3Casts"`
	Spell4Casts                    int               `json:"spell4Casts"`
	Summoner1Casts                 int               `json:"summoner1Casts"`
	Summoner1ID                    int               `json:"summoner1Id"`
	Summoner2Casts                 int               `json:"summoner2Casts"`
	Summoner2ID                    int               `json:"summoner2Id"`
	SummonerID                     string            `json:"summonerId"`
	SummonerLevel                  int               `json:"summonerLevel"`
	SummonerName                   string            `json:"summonerName"`
	TeamEarlySurrendered           bool              `json:"teamEarlySurrendered"`
	TeamID                         int               `json:"teamId"`
	// Both individualPosition and teamPosition are computed by the game server and are
	// different versions of the most likely position played by a player. The individualPosition
	// is the best guess for which position the player actually played in isolation of
	// anything else. The teamPosition is the best guess for which position the player
	// actually played if we add the constraint that each team must have one top player, one
	// jungle, one middle, etc. Generally the recommendation is to use the teamPosition field
	// over the individualPosition field.
	TeamPosition                   string `json:"teamPosition"`
	TimeCCingOthers                int    `json:"timeCCingOthers"`
	TimePlayed                     int    `json:"timePlayed"`
	TotalDamageDealt               int    `json:"totalDamageDealt"`
	TotalDamageDealtToChampions    int    `json:"totalDamageDealtToChampions"`
	TotalDamageShieldedOnTeammates int    `json:"totalDamageShieldedOnTeammates"`
	TotalDamageTaken               int    `json:"totalDamageTaken"`
	TotalHeal                      int    `json:"totalHeal"`
	TotalHealsOnTeammates          int    `json:"totalHealsOnTeammates"`
	TotalMinionsKilled             int    `json:"totalMinionsKilled"`
	TotalTimeCCDealt               int    `json:"totalTimeCCDealt"`
	TotalTimeSpentDead             int    `json:"totalTimeSpentDead"`
	TotalUnitsHealed               int    `json:"totalUnitsHealed"`
	TripleKills                    int    `json:"tripleKills"`
	TrueDamageDealt                int    `json:"trueDamageDealt"`
	TrueDamageDealtToChampions     int    `json:"trueDamageDealtToChampions"`
	TrueDamageTaken                int    `json:"trueDamageTaken"`
	TurretKills                    int    `json:"turretKills"`
	TurretTakedowns                int    `json:"turretTakedowns"`
	TurretsLost                    int    `json:"turretsLost"`
	UnrealKills                    int    `json:"unrealKills"`
	VisionScore                    int    `json:"visionScore"`
	VisionWardsBoughtInGame        int    `json:"visionWardsBoughtInGame"`
	WardsKilled                    int    `json:"wardsKilled"`
	WardsPlaced                    int    `json:"wardsPlaced"`
	Win                            bool   `json:"win"`
}

Take note of that Participant object - we'll be using it a lot later!

First, let's get the data for the player. We need the name and the region, since players in different regions can have the same name. I created a object to contain the response:

type FormResponse struct {
	SummonerName string `json:"summonerName"`
	Region       string `json:"region"`
}

and we can switch off that:

        formResp := &models.FormResponse{}
	json.Unmarshal(body, formResp)
	// Make sure that the region is a valid Riot region.
	var region api.Region
	switch strings.ToUpper(formResp.Region) {
	case "EUW":
		region = api.RegionEuropeWest
	case "EUNE":
		region = api.RegionEuropeNorthEast
	case "NA":
		region = api.RegionNorthAmerica
	case "BR":
		region = api.RegionBrasil
	case "KR":
		region = api.RegionKorea
	case "JP":
		region = api.RegionJapan
	case "LAN":
		region = api.RegionLatinAmericaNorth
	case "LAS":
		region = api.RegionLatinAmericaSouth
	case "OCE":
		region = api.RegionOceania
	case "RU":
		region = api.RegionRussia
	default:
		adapter.Logger.Print(`Invalid region provided.`)
		_ = marshalAndWriteErrorResponse(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return

	}

Admittedly, the code for getting the region isn't very nice, but it does the job. It can be extended if Riot opens up any more regions, but it won't work for any Tencent owned servers. We need the region for creating the Golio client, from which we can make more requests:

	client := golio.NewClient(api_key, golio.WithRegion(region), golio.WithLogger(logrus.New()))

After creating the client, we can get the player now, using the Golio function GetByName:

// GetByName returns the summoner with the given summoner name
func (s *SummonerClient) GetByName(name string) (*Summoner, error) {
	return s.getBy(identificationName, name, s.logger().WithField("method", "GetByName"))
}
summoner, err := client.Riot.LoL.Summoner.GetByName(formResp.SummonerName)
	if err != nil {
		adapter.Logger.Printf(`error while getting summoner: %s`, err.Error())
		_ = marshalAndWriteErrorResponse(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return
	}

Now, for the fun part! Riot has a... funky system for getting the match history for players, which I wish they would improve at some point as it would make my life so much easier. First we need to get a stream for all the matches that the player has:

matchHistoryStream := client.Riot.LoL.Match.ListStream(summoner.PUUID)
	var matchIDs []string
	for k := range matchHistoryStream {
		matchIDs = append(matchIDs, k.MatchID)
	}

We then have to loop over all the match IDs from the stream since getting the match data from Riot's API requires a match ID:

	var matchHistoryActual []*lol.Match
	for _, v := range matchIDs {
		match, _ := client.Riot.LoL.Match.Get(v)

		matchHistoryActual = append(matchHistoryActual, match)
	}

In a roundabout way, we now have all the match history. What do we do with it now? Remember the data points we talked about earlier, re: the champions these bots often play, the winrates etc? Now it's time to get it. I've created placeholder values for some important data I want to capture, including:

var champsPlayed []string

	var wins []bool
	var winrate int
	var cs []int
	var kp []int
	var dmg []int
	var champsData []*models.ChampionData
	var botIntroGamesPlayed []string
	// while going through match history, filter for only these particular champs: Yuumi, Janna, Sona, Soraka, Ammumu, Taric, Morgana
	for _, v := range matchHistoryActual {
		// also need to check for large amount of bot games
		if v.Info.QueueID == 830 { // bot intro games
			// just check for the amount of them tbh...
			botIntroGamesPlayed = append(botIntroGamesPlayed, v.Metadata.MatchID)
		}
		// get match info here
		// make sure we get partipatnt info for the correct user
		if v.Info.QueueID == 420 { // I see what you did there riot, 420 is the id for ranked games
			id := fmt.Sprint(v.Info.GameID)
			for _, v := range v.Info.Participants {
				if v.PUUID == summoner.PUUID {
					// We only want data if it's for the champions listed above...
					if v.ChampionName == "Yuumi" || v.ChampionName == "Janna" || v.ChampionName == "Sona" || v.ChampionName == "Soraka" || v.ChampionName == "Amumu" || v.ChampionName == "Taric" || v.ChampionName == "Morgana" {
						champsPlayed = append(champsPlayed, v.ChampionName)

						if v.Win {
							wins = append(wins, v.Win)
						}
						win := len(wins)
						winrate = win / len(matchHistoryActual) * 100

						cs = append(cs, v.TotalMinionsKilled)

						killPart := v.Kills + v.Assists
						kp = append(kp, killPart)

						dmg = append(dmg, v.TotalDamageDealt)

						wardStats := make(map[string]int)
						// And if they're in the support role, check for amount of wards bought/placed??
						if v.TeamPosition == "UTILITY" {
							wardStats["wardsKilled"] = v.WardsKilled
							wardStats["wardsPlaced"] = v.WardsPlaced
							wardStats["pinkWards"] = v.DetectorWardsPlaced
						}

						var summonerSpells []string
						summonerSpell1, _ := client.DataDragon.GetSummonerSpell(fmt.Sprint(v.Summoner1ID))
						summonerSpell2, _ := client.DataDragon.GetSummonerSpell(fmt.Sprint((v.Summoner2ID)))
						summonerSpells = append(summonerSpells, summonerSpell1.Name)
						summonerSpells = append(summonerSpells, summonerSpell2.Name)

						// get items with datadragon
						var items []string
						i1, _ := client.DataDragon.GetItem(fmt.Sprint(v.Item1))
						i2, _ := client.DataDragon.GetItem(fmt.Sprint(v.Item2))
						i3, _ := client.DataDragon.GetItem(fmt.Sprint(v.Item3))
						i4, _ := client.DataDragon.GetItem(fmt.Sprint(v.Item4))
						i5, _ := client.DataDragon.GetItem(fmt.Sprint(v.Item5))
						i6, _ := client.DataDragon.GetItem(fmt.Sprint(v.Item6))
						items = append(items, i1.Name)
						items = append(items, i2.Name)
						items = append(items, i3.Name)
						items = append(items, i4.Name)
						items = append(items, i5.Name)
						items = append(items, i6.Name)

						// For the new jungler bots, check how many objectives have been taken. This isn't perfect - I've had games as jg where I've never been able to get a single objective (thanks botlane!!)
						objectives := make(map[string]int)
						if v.TeamPosition == "JUNGLE" {
							baronKill := v.BaronKills
							drag := v.DragonKills
							dmgObj := v.DamageDealtToObjectives
							objectives["BaronKills"] = baronKill
							objectives["DragonKills"] = drag
							objectives["DamageDealtToObjectives"] = dmgObj
						}

						// place this somewhere else i guess idk i'm writing this at 3am and i'm off my meds so fuck me i guess
						champData := createNewChampData(v.ChampionName, cs, kp, dmg, v.TeamPosition, objectives, id, wardStats, summonerSpells, items)
						champsData = append(champsData, champData)

					}
				}
			}

		}

	}

We've gathered the data, but what good is the data if we can't do anything with it? I've created another object that would take the data we've gathered and it can be turned into a CSV:

csv := &models.SummonerCSV{
	SummonerName:            summonerData.SummonerName,
	AverageKillParticaption: v.AverageKillParticaption,
	AverageCreepScore:       v.AverageCreepScore,
	AverageDamage:           v.AverageDamage,
	TeamPosition:            v.TeamPosition,
	ObjectiveDmg:            v.ObjectiveDmg,
	GameID:                  v.GameID,
	WardStats:               v.WardStats,
	SummonerSpells:          v.SummonerSpells,
	Items:                   v.Items,
	PUUID:                   summonerData.PUUID,
	AccountID:               summonerData.AccountID,
	ChampName:               v.Name,
	Winrate:                 summonerData.Winrate,
	AmountOfGamesPlayed:     summonerData.AmountOfGamesPlayed,
	BotGamesPlayed:          summonerData.BotGamesPlayed,
}

Let's send this bad boy to Golearn! We take the object we created above and send it to the function AnalyseSummoner which will return a handy Result object for the end user. Result is a quick and dirty object of my own creation which has a boolean if the supplied summoner was a bot or not, and the prediction supplied from Golearn. Golearn uses Instances instead of your usual DataFrames that we're used to, so first we create a Dataframe of all the important information we collected in the last function:

Data are loaded in as Instances. You can then perform matrix like operations on them, and pass them to estimators. GoLearn implements the scikit-learn interface of Fit/Predict, so you can easily swap out estimators for trial and error. GoLearn also includes helper functions for data, like cross validation, and train and test splitting.

We then convert it into an Instance, so we can then perform operations on it :D

func AnalyseSummoner(csv []*models.SummonerCSV) (*models.Result, error) {
	for _, v := range csv {
		var s7, s8, s9, s10 dataframe.Series
		// might need to loop through the maps
		s1 := dataframe.NewSeriesString("summoner_name", nil, v.SummonerName)
		s2 := dataframe.NewSeriesInt64("average_kill_participation", nil, v.AverageKillParticaption)
		s3 := dataframe.NewSeriesInt64("total_games_played", nil, v.AmountOfGamesPlayed)
		s4 := dataframe.NewSeriesInt64("average_cs", nil, v.AverageCreepScore)
		s5 := dataframe.NewSeriesString("position", nil, v.TeamPosition)
		s6 := dataframe.NewSeriesInt64("average_damage", nil, v.AverageDamage)
		for _, v := range v.ObjectiveDmg {
			s7 = dataframe.NewSeriesInt64("damage_to_objectives", nil, v)
		}
		for _, v := range v.WardStats {
			s8 = dataframe.NewSeriesInt64("wards", nil, v)

		}
		for _, v := range v.SummonerSpells {
			s9 = dataframe.NewSeriesString("summoner_spells", nil, v)
		}
		for _, v := range v.Items {
			s10 = dataframe.NewSeriesString("items", nil, v)
		}
		s11 := dataframe.NewSeriesInt64("winrate", nil, v.Winrate)
		s12 := dataframe.NewSeriesInt64("bot_games", nil, v.BotGamesPlayed)

		df := dataframe.NewDataFrame(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12)

		// now that we've created the df, convert to w/e golearn uses
		instance := base.ConvertDataFrameToInstances(df, 1)

We have our dataframe, let's compare it to our training data (aka a list I gathered of some bots on the farest pages of op.gg) I used the training data I had to build up an ID3 tree, which was shamelessly copied from the example here.

I am not a data scientist, I am an idiot who can sometimes code good :)

predictions, err = tree.Predict(instance)
	if err != nil {
		panic(err)
	}
var isBot bool
if predictions > 0.9 {
   isBot = true
}else{
   isBot = false
}

result := &models.Result{
   IsBot: isBot,
   Result: ""
}
return result

After we get our prediction, we can assume the player is a bot or not. It's simple, it's probably not enough, but for my purposes, it works :)

So, what have we learnt?

Ah, the question I always hate asking myself after every project...

I learnt that the support for machine learning in Go is actually really good! I mean, nothing is going to replace Python for data science purposes, not for a long time, but if you have to write it in Go Golearn is really good. I'm not a maths person (ask me how I failed my foundation Maths GCSE... twice) but their instructions are easy to understand, for someone which often has data science terms fly over their head. If I had a similar project in Go, I'd use Golearn again.

Would a project like this solve the problem of botting in League? I'm not that self-important enough to say that this will solve it (and world hunger etc :) ). I don't have the amount of resources that Riot would have dedicated for this. Again, eejit who codes good sometimes.

There is also the looming problem of false positives. I think some of the data points could unintentionally catch out players who are just "unlucky" and be detected as bots while in reality they aren't. I think even Riot are affected by this - in a video by DongHuap he states that Riot went out and banned all Yuumi players in Iron 4 since Yuumi bots were so common, regardless if they were bots or not. I'm not too sure how to solve this problem in all honesty.

Anyway, if you're interested in this project, feel free to look at it in my Github! At the time of writing this, I still need to push up the updated machine learning bit, so maybe this blog post will serve as an impetus :) Thanks for reading!