Alt Detection through Badge Checking

About

Alts tend to be an issue in a community I’m in, so I made an alternate account detection concept using Python that graphs a user’s history of getting badges in games using Roblox’s APIs. It charts the overall count of badges and plots when they were obtained. So instead of manually going through a profile’s badges looking for a low number of badges or a spam of badges from a badge walk game, the graph makes it easy to see at a glance.


How to use

You can run it yourself using the Google Colab Link, it’s a relatively simple plotting script. You can change the username strings of the USERNAMES list to graph different users. In Colab, you can press the triangle run button to see it working, no coding experience required.

It works by gathering all badges that a user has and running the “Awarded Date” API on each one, so that we can get how many badges they earned and when they got it. On the bottom of this post, I also copied the full code, so that you can run it locally as well (requires Python experience).

badgecheck


Detecting Alts

Alternate accounts in these graphs tend to have:

  • Few badges in number
  • Large spaces of time where they don’t earn a lot (or any) badges
  • A sudden spike in badges from badge walking games or collecting them

The graph makes it easy to see a person’s badge history at a glance without scrolling their profile.
suddengrowth

Attempting to fake a badge history requires being able to consistently get badges over periods of years, and at that point, you can basically consider it as a main account. The graph is based on the assumption that active accounts often play games that give them new badges (even randomly) and faking it requires too much play time to consistently work around.

You can find periods of time where a user was inactive or played a lot, and compare their badge history to what they say they did on Roblox. It’s not perfect, but it covers a lot of low effort alt attempts. Be careful using this alone to declare if a user is an alt, it’s recommended to combine this with other common analysis methods.


Problems and Future Work

Some potential issues:

  1. Users can have an inactivity excuse (or “quit the game”)
  2. Users can buy an active account and continue using it as their new main
  3. Users with an extremely high number of badges take longer to graph
  4. Users can delete their badges
  5. Users can solely play a single game (or a few) which limits their badge growth
  6. API can rate limit with too many calls on the same user

In the future, someone could possibly make a machine learning method to detect alts with this. I started this project a year ago and made the code free to use, so other people have used it for their own work. They’ve made the graph look nicer, the program run faster, and also have put it into Discord Bots to make it easier for them to look up users. From use cases I’ve seen, it seems fairly stable and accurate for other groups in the community to check for alts.

Full Code Below:

"""
Author: a_cemaster
Date: 2/16/2024

This Python script creates a graph representing the cumulative count of badges earned by a Roblox user over time.
It fetches badge data using the Roblox API and plots the cumulative count of badges against their awarded dates.

If you're running this yourself, a window usually pops up with the graph.
If you're running this in Colab or Jupyter Notebook, then the image will show on the output.

You can also save the plot with the commented code 'plt.savefig(...)'

Some Roblox users have a lot of badges, which makes this script take a long time.

CHANGE LOGS:
- 9/7/2024: Using a proxy for Awarded Dates API for significant speed up, also set STEP = 100
"""
"""
Author: a_cemaster
Date: 2/16/2024

This Python script creates a graph representing the cumulative count of badges earned by a Roblox user over time.
It fetches badge data using the Roblox API and plots the cumulative count of badges against their awarded dates.

If you're running this yourself, a window usually pops up with the graph.
If you're running this in Colab or Jupyter Notebook, then the image will show on the output.

You can also save the plot with the commented code 'plt.savefig(...)'

Some Roblox users have a lot of badges, which makes this script take a long time.

CHANGE LOGS:
- 9/7/2024: Using a proxy for Awarded Dates API for significant speed up, also set STEP = 100
- 3/14/2025: More proxy usage, also added a "can view inventory" print due to player badges being private with hidden inventory, removed concurrency
"""
import requests
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

from datetime import datetime
from time import sleep

# Put usernames in this list to get their badge information
USERNAMES = ["a_cemaster", "w_armaster", "kielthenoob"]

# Will print current amount of badges tracked if True
# Recommended if user has a lot of badges and you want to see progress
PRINT_PROGRESS = True
BATCH_PER_PRINT = 1000

def get_user_id_from_username(username: str) -> str:
    # Use the Roblox users API to get the user ID from the username
    url = f"https://users.roblox.com/v1/usernames/users"
    params = {"usernames": [username]}
    response = requests.post(url, json=params)
    data = response.json()

    # Check if the response is valid and contains the user ID field
    if response.status_code == 200 and data["data"]:
        return data["data"][0]["id"]

    raise Exception(f"Could not find the user ID for username {username}")

def check_can_view_inventory(user_id: str) -> bool:
    """
    Given a Roblox user id, check if the user can view their inventory.
    """
    url = f"https://inventory.roproxy.com/v1/users/{user_id}/can-view-inventory"
    response = requests.get(url)
    # If we get a 429 status, wait and retry with some backoff
    retry_after = 5
    while response.status_code == 429:
        print(f"Rate limited. Retrying after {retry_after} seconds.")
        sleep(retry_after)
        response = requests.get(url)
        retry_after += 5

    response.raise_for_status()  # Raise an error for other non-200 responses
    data = response.json()
    return response.json()["canView"]

def fetch_badges(user_id: str) -> list[dict]:
    """
    Given a Roblox user id, get the user's badge data.
    """
    url = f"https://badges.roproxy.com/v1/users/{user_id}/badges?limit=100&sortOrder=Desc"
    badges = []
    cursor = None

    while True:
        params = {}
        if cursor:
            params['cursor'] = cursor

        response = requests.get(url, params=params)
        data = response.json()

        for badge in data['data']:
            badges.append(badge)
            if PRINT_PROGRESS and len(badges) % BATCH_PER_PRINT == 0:
                print(f"{len(badges)} badges for {user_id} requested.")

        if data['nextPageCursor']:
            cursor = data['nextPageCursor']
        else:
            break

    return badges

def convertDateToDatetime(date: str) -> datetime:
    """
    Given a timestamp string, convert to a datetime object.
    """
    milliseconds_length = len(date.split('.')[-1])

    # The string dates we get vary, so we have to sanitize to 3 places and 'Z'
    # Truncate if more than 3 places & 'Z', else pad zeroes
    if '.' in date:
        if milliseconds_length > 4:
            dotInd = date.find('.')
            date = date[:dotInd + 4] + date[-1]
        elif milliseconds_length < 4:
            date = date[:-1] + '0' * (4 - milliseconds_length) + date[-1]
    else:
        # There is no decimal portion, so we have to add it
        date = date[:-1] + ".000" + date[-1]

    return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ")

def fetch_award_dates(user_id: str, badges: list[dict]) -> list[str]:
    """
    Make requests to Roblox's Badge API to get user's badge awarded dates
    """
    dates = []
    badge_ids = [badge["id"] for badge in badges]
    url = f"https://badges.roproxy.com/v1/users/{user_id}/badges/awarded-dates"
    STEP = 100  # Adjust the step size as needed, we can't do too many at once

    for i in range(0, len(badge_ids), STEP):
        try:
            params = {"badgeIds": badge_ids[i:i + STEP]}
            response = requests.get(url, params=params)

            # If we get a 429 status, wait and retry with some backoff
            retry_after = 5
            while response.status_code == 429:
                print(f"Rate limited. Retrying after {retry_after} seconds.")
                sleep(retry_after)
                response = requests.get(url, params=params)
                retry_after += 5

            response.raise_for_status()  # Raise an error for other non-200 responses

            for badge in response.json()["data"]:
                dates.append(badge["awardedDate"])
                if PRINT_PROGRESS and len(dates) % BATCH_PER_PRINT == 0:
                    print(f"{len(dates)} awarded dates for {user_id} requested.")

        except Exception as e:
            print(f"Error fetching data: {e}")

    return dates

def plot_cumulative_badges(username: str, user_id: str, dates: list[str]):
    """
    Graph the cumulative total of badges over time
    """
    # Sort badges by awarded date
    y_values = [convertDateToDatetime(date) for date in dates]
    y_values.sort()

    # Calculate cumulative count at each date and store into a list
    curr_count = 0
    cumulative_counts = []
    for date in y_values:
        curr_count += 1
        cumulative_counts.append(curr_count)

    # Plot the cumulative count over time
    plt.style.use('dark_background')
    plt.xlabel('Badge Earned Date')
    plt.ylabel('Total Badges')
    plt.title(f'Badges over Time for {username} ({user_id})')
    plt.scatter(y_values, cumulative_counts, marker='o', alpha=0.2)

    # Set the X-axis format to 'Year' only
    ax = plt.gca()
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    ax.xaxis.set_major_locator(mdates.YearLocator())

    plt.figtext(0.05, 0.95, f"Badge Count: {len(y_values)}", ha="left", va="top", color="white", transform=ax.transAxes)

    # Save the image, if desired. Must be ran before plt.show()
    # plt.savefig(f"BadgeCount-{username}-{user_id}.png", bbox_inches="tight")

    plt.tight_layout()
    plt.show()

def process_user(username: str):
    """
    Run all the functions to get the badge graph for single username
    """
    user_id = get_user_id_from_username(username)
    can_view_inventory = check_can_view_inventory(user_id)
    print(f"{username}: {user_id}, Can view inventory: {can_view_inventory}")
    badges = fetch_badges(user_id)
    dates = fetch_award_dates(user_id, badges)
    plot_cumulative_badges(username, user_id, dates)
    print(f"Completed plot for {username} - {user_id}")

def main():
    for username in USERNAMES:
        process_user(username)

if __name__ == "__main__":
    main()

tl;dr I made a Python script to see if an account often plays games and earns badges.

43 Likes

This is a really creative approach toward addressing the issue of alts and generally auditing an account’s activity period! I’ve seen this approach applied catch alts very effectively in the clan community.

11 Likes

Thanks, with how Roblox’s alt detection has either been ineffective or delayed over the past few years, I thought I might as well make a possible solution.

2 Likes

Sensational. This blessing on the community has left me teary-eyed. Thank you for your commitment.

5 Likes

Great tool!! I know a lot of communities struggle with combating alts hopefully this helps.

2 Likes

Yeah I’ve seen it has been useful in gun clan and sword clan communities for quickly identifying potential alts. Especially nice for communities that deal with cheaters and degens that go on alternative accounts.

Roblox made it so the script would not show badges for users with a hidden inventory. Communities enforcing a badge check will also have to enforce a public inventory.

EDIT: I’ve made an update that will print whether the program can see the inventory of a user. The script should still work for profiles with a public inventory.

1 Like

So, forcing a player to have their whole inventory on display for anyone to see just to play the game?

1 Like

That’s how some groups work to deter alting exploiters and community-banned users. Once a badge graph is obtained, a player can close their inventory again after confirmation they’re not an alt. This isn’t a catch all for a large scale game, but more for smaller organized community events where alts are a problem.

If someone isn’t comfortable with opening their inventory, then a community can either refuse to let them in due to risk of being a prior banned user or look for another way to verify that it’s a main account. In any case, this could all be resolved if Roblox had a reliable alt detection or cheat detection system for these communities, but until then, badges are one way to determine this.

4 Likes

Can’t games prompt users for permission to access their inventory?

Sure, but that’s up to the user to make their way out of the game to open their inventory to identify that they aren’t an alternate account.

For clarification, the badge graphing program is just a visual, so this is more useful for external communities on Guilded or Discord that have to vet accounts, instead of an automated in-game classifer (Although, it’s possible with some statistics or machine learning techniques).

2 Likes