Running an API-driven Community Event for Battleborn

Nov 5, 2018 | v1.0

Introduction

Back in May 2018, the Gearbox community got together to celebrate the 2nd anniversary since Battleborn launched. This article will look at how APIs can be used to power community events.


A Brief History of Battleborn

Originally released May 3, 2016, Battleborn launched during the beta period for Overwatch and got a large amount of negative flak from the media and gamers alike.

2018 was not a great year for hero shooters. Many of the notable contenders in the space had begun announcing the shut down of their game servers as the developers shifted their focus to other projects instead of trying to compete with Overwatch, which had been dominating the market.

In the wake of all these announcements, Creative Director Randy Varnell posted a message to the community that assured fans there were no plans to shut down the Battleborn servers anytime soon.


Brainstorming for the 2nd Anniversary

It was a rough time to be a fan of Battleborn, so myself and several others wanted to celebrate the fact the game had made it to its 2nd year. Initially the plan was to run another Battleborn Day; a community driven event that originated on Reddit. The idea was to get as many players on during a specific day/weekend while encouraging new players to try the game. It was not restricted to a given day in the year, but whenever the community wanted to band together and play the game.

The organizers got together and brainstormed what kind of activities we could have.

Beya, one of the pillars of the community and an amazing artist wanted to run an art contest. (Cosplay by NatsumeRyu)

MINREC thought it would be cool to offer up daily screenshot challenges.

I also wanted to revisit an idea I was tossing around since before the previous community event, which was to run a loot hunt.

We later decided to run a sticker side quest during the event where participation and/or engagement on social media would unlock special stickers folks could use to customize their own Bro Certificate.

Oscar Mike (pictured above) is a playable character from a batch of clones that were deployed to fight in an occupation without much thought into what came after. They were basically children in adult bodies who over time had to figure out their own individuality despite all being clones. To Oscar, everyone is a "bro", and earning a certificate was heavily tied to his character and his desire to be friends with everyone he meets.


The Loot Hunt

The community around Borderlands run a loot hunt at the start of every year. Participants created a new character and had to collect as many legendary item drops as they could over the span of a week. Legendaries can drop from random sources, and also as unique drops from bosses.

What was interesting though, was that the organizers had been running the event out of a google spreadsheet where each player was required to manually manage their progress. They were also required to stream their game and create clips showing when a drop occurred. Despite how involved this process was, they still regularly saw quite a number of participants each year, which is a testament to the playerbase around the Borderlands series.

Borderlands 2 Loot Hunt Player Spreadsheet (A-D)

I had been a part of the Destiny developer community for several years and knew how powerful APIs can be for automation. I wanted to see whether it could be applied to a loot hunt type event for Battleborn. There were no APIs for the game so I had to build my own APIs and datamine the game files to get the information I needed.

Part of the reason why it took so long to implement a loot hunt event was because at one point it seemed like the game might actually get some public APIs, which I had been advocating for over a year. I put my event plans on hold to give the developers time to build these APIs, but a few months later it was announced that all work had ceased on the game taking any chance of it happening with it.

Gear search engine for Battleborn

Up until that point, I had done various projects that used datamined assets and information from the game. I had already built a search engine for finding a given item drop, so it was more a matter of re-purposing past projects into a new interface for tracking and managing your progress for the loot hunt.

I didn't want to deal with the uploading and storing of screenshots and user data. I was also aware that the community was segmented across several social media platforms including the unofficial Discord, Twitter and the Gearbox Forums. Rather than force folks to use one system, I looked at what APIs were available for each of these platforms. The goal was to create a universal way for players to submit a screenshot of their item drop, tagged with the name and optionally the platform they are playing on.

Example loothunt submission from a Playstation 4

Sharing screenshots is a standard feature of console platforms and I wanted to make it as simple as possible for players to participate.


The Social Media Bot

For Discord, I had already built a helper bot that responded to search queries, forward them to my website and return information like where an item can drop or an image of the item card directly into the chat. The only real changes that needed to happen here was repackaging the code into a special event build that used a different command !drop <gear> <platform>.

Lowlibot reacting to queries in the Discord chat

Here's an excerpt of the code I wrote that reacts to queries in the chat using the Discord APIs. The server admins would typically set up a special bot channel for interacting with bots to limit their interaction with the server.

var bot = new Discord.Client();

bot.on('ready', function() {
    console.log('DiscordBot: Connected');
});

bot.on('message', function(message) {
    if (options.ignoreBots && message.author.bot) {
        //console.log('Ignored "'+message.author.username+'" bot.');
        return;
    }

    //console.log(message);

    var exp = new RegExp(options.prefix+options.command, 'g');
    var matches = message.content.match(exp);
    if (matches) {
        var match = matches[0];
        console.log('DiscordBot: Request "'+message.content+'"');

        // Prevent spamming commands
        var now = new Date().getTime();
        if (lastPing[message.guild.id] != undefined && now-lastPing[message.guild.id] < pingDelay) {
            var pingRemain = pingDelay-(now-lastPing[message.guild.id]);
            console.log('DiscordBot: Warning - "'+message.guild.name+'" needs to wait ~'+(Math.round(pingRemain/100)/10)+'s to make another request.');
            DiscordBot.doReact(message, 'rage');
            return;
        }
        lastPing[message.guild.id] = now;
        self.handleRequest(message, options);
    }
});

static doReact(message: any, emojiName: string) {
    var emojiCode = 0;
    switch(emojiName) {
        case 'thinking': emojiCode = 0x1F914; break;
        case 'rage': emojiCode = 0x1F621; break;
        case 'sunglasses': emojiCode = 0x1F60E; break;
        case 'sweat': emojiCode = 0x1F613; break;
        case 'sleep': emojiCode = 0x1F634; break;
    }
    if (emojiCode) message.react(String.fromCodePoint(emojiCode));
}

handleRequest(message: any, options:any) {
    var match = message.content.replace(options.prefix+options.command, '');
    match = match.replace(/![^\s]+\s/, '').toLowerCase().replace(/[^a-z\s\-\.0-9]+/g, '').trim();

    DiscordBot.doReact(message, 'thinking');

    if (match == 'help') {
        //DiscordBot.doHelp(message, options);
    }
    else {
        this.doQuery(message, options, match);
    }
}

doQuery(message:any, options:any, match:string) {
    //console.log('Query', match);
    var platform = this.getPlatform(match);
    match = this.stripPlatform(match);

    var results = this.findItem(match);

    var promise = null;

    var defaultReaction = results.length > 0 ? 'sunglasses' : 'sweat';

    // Skip if not matches
    if (results.length == 0) {
        DiscordBot.doReact(message, 'sweat');
    }
    // Exact item match
    else if (results.length == 1) {
        var attachments = message.attachments.array();
        if (attachments.length == 0) {
            defaultReaction = 'sweat';
            promise = message.reply('No image attached!');
        } else {
            var attachment = attachments[0];
            var item = results[0];
            var weight = this.getItemWeight(item.id);
            var drop = {
                source: 'discord',
                sourceId: message.author.username+'#'+message.author.discriminator,
                link: attachment.url,
                itemId: item.id,
                weight: weight,
                platform: platform,
                timestamp: Math.floor(new Date().getTime()/1000)
            };
            //console.log('Drop', drop);
            this.postDrop(drop).then(function(res) {
                if (res.success) {
                    DiscordBot.doReact(message, 'sunglasses');
                } else {
                    DiscordBot.doReact(message, 'sweat');
                    message.reply('Couldn\'t find your profile.');
                    console.log(res);
                }
            }).catch(function(error) {
                console.error(error);
            });
        }
    }
    // Help user refine search
    else {
        var intro = 'I found '+results.length+' matches.';
        intro += "\n";

        var reply = '';
        for (var i=0; i<results.length; i++) {
            var result = results[i];
            var line = options.prefix+options.command+' '+result.name.replace(/[^a-zA-Z\s\-\.0-9]+/g, '');

            line += "\n";
            reply += line;
        }

        promise = message.reply(intro+reply);
    }

    if (promise) {
        promise.then(function() {
            DiscordBot.doReact(message, defaultReaction);
        });
    }
}

I had worked with the Twitter APIs before as part of my job as a web developer. The API returns a lot of information about a tweet making it fairly straightforward to set up a hashtag like #loothunt and just periodically check for new tweets. Once you strip out the hashtag references, you end up with a searchable string you can plug into the search API I had built for the Discord bot.

private checkTwitter(q?:string, count?:number) {
    var query = {
        'q': q ? q : this.query,
        'result_type': 'recent',
        'count': count ? count : this.count,
        //'lang': 'en',
        'tweet_mode': 'extended'
    };
    return ApiRequest.doGet(this.domain, '/1.1/search/tweets.json?'+querystring.stringify(query), {
        'Authorization': 'Bearer '+this.accessToken
    });
}

public refresh() {
    console.log('TwitterBot: Checking Twitter...', new Date());

    var self = this;
    this.checkTwitter().then(function(res) {
        //console.log('Results', res);
        var queueIsEmpty = self.postQueue.length == 0;
        if (res.statuses) {
            console.log('TwitterBot: Found '+res.statuses.length+' Tweets');
            for (var i=0; i<res.statuses.length; i++) {
                var status = res.statuses[i];

                var createdAt = status.created_at;
                var username = status.user.screen_name;
                var postContent = status.full_text;
                var query = postContent;

                //console.log('TwitterBot: '+username, postContent);

                // Ignore retweets
                if (status.retweeted_status) continue;

                // Ignore replies
                if (status.in_reply_to_status_id_str) continue;

                var media = null;

                for (var entityType in status.entities) {
                    var entities = status.entities[entityType];
                    for (var j=0; j<entities.length; j++) {
                        var entity = entities[j];
                        var search = postContent.slice(entity.indices[0], entity.indices[1]+1);
                        query = query.replace(search, '');

                        // Grab the first media attachment
                        if (!media && entityType == 'media' && entity.type == 'photo') {
                            //console.log('Media['+j+']', entity);
                            media = entity;
                        }
                    }
                }

                query = query.trim();

                // Ignore tweets without an image
                if (!media) continue;

                self.postQueue.push({
                    id: status.id_str,
                    user: status.user,
                    username: username,
                    createdAt: createdAt,
                    query: query,
                    media: media,
                    hashtags: status.entities.hashtags
                    //status: status
                });

                //console.log('Tweet['+i+']: '+username, "\n", '"'+query+'"', "\n", '"'+postContent+'"', "\n");
            }
        }

        if (queueIsEmpty) self.checkQueue();
    });
}

public checkQueue() {
    if (this.postQueue.length == 0) return;
    var post = this.postQueue[0];

    var self = this;

    this.checkPost(post).then(function() {
        self.postQueue = self.postQueue.slice(1);
        self.checkQueue();
    });
}

public checkPost(post):Promise<any> {
    var self = this;
    return new Promise(function(resolve, reject) {
        if (self.postIds.indexOf(post.id) != -1) {
            resolve();
            return;
        }
        switch(self.mode) {
            //case 'artcontest': self.checkArtContestPost(post, resolve, reject); break;
            case 'loothunt': self.checkLootHuntPost(post, resolve, reject); break;
            //case 'snapcontest': self.checkSnapContestPost(post, resolve, reject); break;
        }
    });
}

private checkLootHuntPost(post, resolve, reject) {
    var self = this;

    var query = post.query.toLowerCase();

    var platform = self.getPlatform(query);
    query = self.stripPlatform(query);

    if (query.length > 35) {
        console.log('TwitterBot: PostDrop '+post.id+' bad query "'+query+'".');
        //self.logPost(post.id);
        resolve();
        return;
    }

    // Skip if not an exact item match
    var results = self.findItem(query);
    if (results.length != 1) {
        if (results.length == 0) console.log('TwitterBot: PostDrop '+post.id+' no matches.');
        else console.log('TwitterBot: PostDrop '+post.id+' found '+results.length+' matches.');
        //self.logPost(post.id);
        resolve();
        return;
    }
    var item = results[0];
    var weight = self.getItemWeight(item.id);

    var drop = {
        source: 'twitter',
        sourceId: post.username,
        link: post.media.expanded_url,
        mediaLink: post.media.media_url_https,
        itemId: item.id,
        weight: weight,
        platform: platform,
        timestamp: Math.floor(new Date(post.createdAt).getTime()/1000)
    };

    console.log('TwitterBot: PostDrop', new Date(drop.timestamp*1000), drop.sourceId, item.name);

    self.postDrop(drop).then(function(res) {
        console.log('TwitterBot: PostDrop', drop.sourceId, item.name, res);
        //self.logPost(post.id);
        resolve();
    }).catch(function(e) {
        reject(e);
    });
}

It was a bit more complicated adding support for the Gearbox forums. I contacted the admins about getting API access, and it turns out the forums use Discourse, which doesn't require authenticated access when just checking for new comments on a given forum thread.

The API only returns the first 100 comments along with the thread details, meaning you have to poll each new comment after that individually to get its contents. This meant I needed to set up a queuing system to watch for new comments that made sure not to poll comments it had already checked. Doing it this way though did mean users could not edit their comments afterwards and retroactively see the updated changes.

I had permission to run the submission thread as I saw fit, so some allowances were made for doing things like double posting. On top of that, the APIs returned comments as raw text, so some pre-processing was required in order to detect valid submissions and I advised players to include #loothunt in their comments and to keep discussion of the event separate.

Screenshot submissions was a bit finicky as the WYSIWYG editor could sometimes generate the image reference differently, fortunately the forum admins helped set up a private thread where I could run tests and figure out all the quirks.

// http://docs.discourse.org/#tag/Topics%2Fpaths%2F~1t~1%7Bid%7D.json%2Fget
private getTopic(id:any):Promise<any> {
    return ApiRequest.doGet(this.domain, '/t/'+id+'.json');
}

// http://docs.discourse.org/#tag/Posts%2Fpaths%2F~1posts~1%7Bid%7D%2Fget
private getPost(id:any):Promise<any> {
    return ApiRequest.doGet(this.domain, '/posts/'+id+'.json');
}

public refresh() {
    console.log('ForumsBot: Checking Forums...', new Date());

    var self = this;

    var request = self.getTopic(self.topicId);
    request.then(function(res) {
        //console.log('Topic', res);

        self.topicCache = res;

        var postStream = res.post_stream;
        //var postIds = self.postIds;

        var queueIsEmpty = self.postQueue.length == 0;

        console.log('ForumsBot: '+postStream.stream.length+' posts found.');

        for (var i=0; i<postStream.stream.length; i++) {
            var postId = postStream.stream[i];

            // Skip the original post
            if (i == 0) continue;

            self.postQueue.push(postId);
        }

        if (queueIsEmpty) self.checkQueue();
    });
}

public checkQueue() {
    if (this.postQueue.length == 0) return;
    var postId = this.postQueue[0];

    var self = this;

    var promise = new Promise(function(resolve, reject) {
        // Skip if post has already been checked before
        if (self.postIds.indexOf(postId) != -1) {
            resolve();
            return;
        }

        //console.log('ForumsBot: Checking Post', postId);

        // Check to see if the post is in the topic cache data
        var topic = self.topicCache;
        for (var i=0; i<topic.post_stream.posts.length; i++) {
            var post = topic.post_stream.posts[i];
            if (post.id == postId) {
                //console.log('TopicPost', post);
                self.checkPost(post).then(function() {
                    resolve();
                }).catch(function(e) {
                    reject(e);
                });
                return;
            }
        }

        var request = self.getPost(postId);
        request.then(function(res) {
            //console.log('SinglePost', res);
            self.checkPost(res).then(function() {
                resolve();
            }).catch(function(e) {
                reject(e);
            });
        }).catch(function(e) {
            reject(e);
        });
    });

    promise.then(function() {
        self.postQueue = self.postQueue.slice(1);
        self.checkQueue();
    }).catch(function(e) {
        console.error('Error', e);
    });
}

public checkPost(post):Promise<any> {
    var self = this;
    return new Promise(function(resolve, reject) {

        var postContent = post.cooked.toLowerCase();

        var checkLoothunt = !self.hashtag || self.hashtag == 'loothunt';
        //var checkArtContest = !self.hashtag || self.hashtag == 'art4solus';

        console.log('ForumsBot: Checking Post', post.id, '| LootHunt: '+checkLoothunt, '| ArtContest: '+checkArtContest);

        // Check to see if it is a #loothunt submission
        if (checkLoothunt && postContent.indexOf('#loothunt') != -1) {
            console.log('ForumsBot: Post '+post.id+' found #loothunt');
            self.checkLootHuntPost(post, resolve, reject);
            return;
        }

        /*if (checkArtContest && postContent.indexOf('#art4solus') != -1) {
            console.log('ForumsBot: Post '+post.id+' found #art4solus');
            self.checkArtContestPost(post, resolve, reject);
            return;
        }*/

        //self.logPost(post.id);
        resolve();
    });
}

private checkLootHuntPost(post, resolve, reject) {
    var self = this;
    var topic = self.topicCache;

    var url = self.domain+'/t/'+topic.slug+'/'+topic.id+'/'+post.post_number;
    var createdAt = post.created_at;
    var updatedAt = post.updated_at;

    var image = self.getImage(post);
    if (!image) {
        resolve();
        return;
    }

    var postContent = post.cooked;
    postContent = self.stripQuotes(postContent);
    postContent = self.cleanPost(postContent);

    var query = postContent.replace(/#loothunt\s*/g, '');
    var platform = self.getPlatform(query);
    query = self.stripPlatform(query);

    if (query.length > 35) {
        console.log('ForumsBot: Post '+post.id+' bad query "'+query+'".');
        //self.logPost(post.id);
        resolve();
        return;
    }

    console.log('ForumsBot: Post '+post.id+' query "'+query+'"');

    // Skip if not an exact item match
    var results = self.findItem(query);
    if (results.length != 1) {
        if (results.length == 0) console.log('ForumsBot: Post '+post.id+' no matches.');
        else console.log('ForumsBot: Post '+post.id+' found '+results.length+' matches.');
        //self.logPost(post.id);
        resolve();
        return;
    }
    var item = results[0];
    var weight = self.getItemWeight(item.id);

    var username = post.username;

    //console.log('Post', username, query, "\n", url, "\n", image, item);

    var drop = {
        source: 'forums',
        sourceId: username,
        link: url,
        mediaLink: image,
        itemId: item.id,
        weight: weight,
        platform: platform,
        timestamp: Math.floor(new Date(createdAt).getTime()/1000)
    };

    self.postDrop(drop).then(function(res) {
        console.log('ForumsBot: PostDrop', '"'+drop.sourceId+'"', '"'+item.name+'"', res);
        //self.logPost(post.id);
        resolve();
    }).catch(function(e) {
        reject(e);
    });
}

private cleanPost(postContent) {
    postContent = postContent
        .replace(/[\n\t]+/g, '')
        .replace(/\<\/*[apib][^>]*\>/g, '')
        .replace(/\<\/*span[^>]*\>/g, '')
        .replace(/\<br\/*\>/g, ' ')
        .replace(/<aside[^>]*>.*<\/aside>/g, '') // Quotes
        .replace(/<div[^>]*class="meta"[^>]*>.*<\/div>/g, '') // Image Meta
        .replace(/<[^>]+>/g, '')
        .trim()
        .toLowerCase()
        ;
    return postContent;
}

private stripQuotes(postContent) {
    var asideIndex;
    while((asideIndex = postContent.indexOf('<aside')) != -1) {
        var endIndex = postContent.indexOf('</aside>', asideIndex)+8;
        postContent = postContent.slice(0, asideIndex)+postContent.slice(asideIndex+endIndex);
    }
    return postContent;
}

private getImage(post):string {
    var postContent = post.cooked;

    postContent = this.stripQuotes(postContent);

    // Skip if no image present
    var images = postContent.match(/src\="([^"]+)"/g);

    if (!images || images.length == 0) {
        console.log('ForumsBot: Post '+post.id+' missing image.');
        //console.log('ForumsBot:', postContent);
        //this.logPost(post.id);
        return null;
    }

    var image = images[0].slice(5, -1);
    if (image.slice(0, 1) == '/') image = 'https://discourse-cdn-sjc1.com/gearbox'+image;

    return image;
}

Polling these 3 APIs was handled by a Node.js bot that I ran locally out of the console. I had a way to receive user submissions from all the major social media platforms, the next hurtle was storing them.


The API Backend

As part of the registration process, players were able to specify which social media platforms they would be submitting from. This was key to making sure trolls couldn't flood the system (a valid concern given how much negativity the community had regularly experienced online) and so players could submit from multiple platforms and still have it count towards their overall progress. Due to the limited time I had to build the system, I chose to let users manually enter their social media handles, but a future iteration would likely try to automate that process as much as possible.

Registration / Settings tab for the Loot Hunt event

The backend part of the system needed to perform several tasks:

  • Validate and track submissions from the bot
  • Return progression info about a given player
  • Return global stats and leaderboards about the event itself

To cut down on server requests, I ported the search API code directly into the bot to speed up it's response time, so the only task it needed to do after it had found a valid submission was send it to the backend, which would return a success or fail status. The benefit of using third party APIs was that all images are housed in their own CDN, so I only needed to store the url to that image along with basic info about the submission itself including:

  • who submitted it
  • when they submitted it
  • what item actually dropped
  • what platform (PC, PS4, Xbox) they were submitting from, which was optional
  • a link to the original submission
  • the current weighted value of that drop
{
    id: "1438",
    itemId: "PF_Gear_HealthRegen_Legendary_ELD",
    link: "https://cdn.discordapp.com/attachments/435608555727486977/442942314772561920/20180506235422_1.jpg",
    mediaLink: null,
    platform: "3",
    source: "discord",
    sourceId: "LĂșcigi#6365",
    timestamp: "2018-05-07T06:54:39Z",
    userId: "57",
    weight: "15"
}

To discourage farming certain missions that had a lot of bosses in them, the organizers decided on a weighting system, where getting duplicates were worth less each time you got one. Someone wanting to rank high on the leaderboards would need to play a variety of PvE content. We chose not to include lootbox drops as we had no way to police players who might have had a stockpile of in-game currency prior to the event and because Battleborn is an online game, we couldn't get participants to start a new save file.

Explanation of the scoring system from the Loot Hunt rules

The Frontend

The frontend side offered the following features:

  • registration and updating settings
  • show overall profile progress
  • check which item drops the player did and didn't have (this also needed to be searchable)
  • global progress (basically an item drop log)
  • global stats about the event
  • leaderboards

The My Progress tab was a retooled version of the Gear Search page. It let users check their progress by item and included drop source information for tracking down item drops they didn't have and was searchable.

The Global Progress tab was basically a submission log for the entire event.

The Global Stats tab was something I built part way into the event to show interesting stats like which platforms players were on and which story missions were getting played the most. It also allowed me to do daily challenges on social media where I encouraged folks to chase after rare drops and get a bit of friendly competition happening between the different platforms.

The Leaderboards tab was where the community could see who was in the running for the top 3 spots. Because we had other events and a limited number of rewards that Mentalmars had organized with 2K/Gearbox, the Top Score board was the only leaderboard that actually mattered. The leaderboards also had the ability to filter by platform.


The Art Contest

The art contest was originally just going to be manually run out of the Gearbox Forums (and later Twitter), however I saw an opportunity to showcase submissions using the same automation code I had written for the Loot Hunt. Any submissions that were tagged with #art4solus would automatically show up on the gallery page. This did require some moderation, however we were fortunate enough that no one decided to troll the hashtag.


The Screenshot Challenge

MINREC's screenshot challenge had the same functionality requirements as the art contest except that it was exclusive to Twitter and would have a different challenge for each day using #FeedMINREC. To keep things simple, we opted to automate specifying which challenge a submission was for and manually fix any errors.


Promoting Streamers

To encourage streamers to stream the game during Battleborn Day, I set up another API bot for checking and updating the live status for every streamer that registered for the event. The idea here was that folks could go to the event hub page and see who was streaming the game at any given time. This list did not favor high profile streamers, rather it simply sorted by online status, giving all streamers an equal opportunity to get highlighted during the event.

Streamers also got a special streamer sticker code to add to their Bro Certificate, which some disappointingly decided to use as an exclusive reward to draw viewers to their streams. There also weren't a lot of streamers who ended up streaming Battleborn for the entire event, so they would get questions from the chat asking why they were not playing Battleborn.

The bot supported Twitch, Mixer and Youtube through each of their APIs. Interestingly, Twitch actually returns information about what game the streamer is currently playing but I decided not to enforce any kind of stream filtering for the event.


public refresh() {
    console.log('SignupBot: Checking Data...', new Date());

    var self = this;

    this.getData().then(function(data) {
        //console.log('Data', data);

        var twitchUsers = [];
        var queueIsEmpty = self.queue.length == 0;

        var newStreams = 0;

        for (var i=0; i<data.length; i++) {
            var entry = data[i];
            //if (entry.platforms.length != entry.links.length) {
            //    console.log('SignupBot: Mismatch number of platforms specified for row '+i+'.');
            //    continue;
            //}
            for (var j=0; j<entry.platforms.length; j++) {
                var platform = entry.platforms[j].toLowerCase();
                var link = entry.platforms.length == 1 && entry.links.length == 1 ? entry.links[0] : null;
                if (!link) {
                    for (var k=0; k<entry.links.length; k++) {
                        var linkEntry = entry.links[k].trim().split(' ')[0];
                        if (linkEntry.toLowerCase().indexOf(platform) != -1) {
                            link = linkEntry;
                            break;
                        }
                    }
                }
                if (!link) {
                    console.log('SignupBot: Could not find '+platform+' link for row '+i+'.');
                    continue;
                }
                var id = link.replace(/^.+\/([^\/]+)$/g, '$1');

                if (platform.indexOf('youtube') != -1) platform = 'youtube';

                if (self.signupIds.indexOf(platform+'-'+id) != -1) continue;

                console.log('SignupBot: ['+i+']', platform, id);
                newStreams++;

                switch(platform) {
                    case 'twitch':
                        twitchUsers.push(id);
                        break;
                    case 'mixer':
                    case 'youtube':
                        self.queue.push({
                            action: 'add',
                            platform: platform,
                            users: [id]
                        });
                        break;
                }

                self.signupIds.push(platform+'-'+id);
            }
        }

        console.log('SignupBot: '+newStreams+' New Streams Found.');

        if (twitchUsers.length > 0) {
            self.queue.push({
                action: 'add',
                platform: 'twitch',
                users: twitchUsers
            });
        }

        if (queueIsEmpty) self.checkQueue();

        self.cache.set('signup', 'signupIds', self.signupIds);
    });
}

// Get a CSV of the google form streamers registered to
private getData():Promise<any> {
    var self = this;
    return new Promise(function(resolve, reject) {
        ApiRequest.doGet(self.domain, self.path).then(function(response) {
            //console.log('Data', response);

            var entries = [];

            fastcsv.fromString(response, {headers: true})
                .on("data", function(data:any){
                    var entry = {};

                    var keys = [
                        'timestamp',
                        'platforms',
                        'links',
                        'timezone',
                        'times',
                        'days',
                        'email'
                    ];
                    var index = 0;
                    for (var key in data) {
                        var value = data[key];
                        var key2 = keys[index++];//key.replace(/[\s]+/g, '-').replace(/[^A-Z0-9\-]+/ig, '').toLowerCase().trim();
                        switch(key2) {
                            case 'platforms':
                            case 'links':
                                value = value.toString().replace(/[\s]+/g, '').split(',');
                                break;
                        }
                        entry[key2] = value;
                    }
                    entries.push(entry);
                })
                .on("end", function(){
                    resolve(entries);
                });
        }).catch(function(e) {
            reject(e);
        });
    });
}

public checkQueue() {
    var self = this;

    if (self.queue.length == 0) return;

    var promise = new Promise(function(resolve, reject) {
        var entry = self.queue[0];

        //console.log('Queue', entry);

        var promise = null;

        switch(entry.platform+'-'+entry.action) {
            case 'youtube-add':
                var username = entry.users[0];
                YoutubeApi.getChannelByUsername(username).then(function(response) {
                    //console.log('Youtube:GetChannelByUsername', response);
                    if (response.items.length == 1) {
                        var channel = response.items[0];
                        var stream = {
                            username: username,
                            userLink: 'https://www.youtube.com/user/'+username,
                            userImage: channel.snippet.thumbnails.high.url,
                            streamType: 'youtube',
                            streamId: channel.id,
                            streamLink: 'https://www.youtube.com/channel/'+channel.id
                        };
                        //console.log('Youtube', stream);
                        self.postStreams([stream]).then(function(response) {
                            console.log('SignupBot: AddYoutube', response);
                            resolve();
                        });
                        return;
                    }
                    resolve();
                });
                break;
            case 'youtube-update':
                var stream = entry.entries[0];
                YoutubeApi.getVideos(stream.streamId).then(function(response) {
                    //console.log('Youtube:GetVideos', response);

                    stream.isLive = response.items.length > 0;

                    self.postStreams([stream]).then(function(response) {
                        console.log('SignupBot: UpdateYoutube', response);
                        resolve();
                    });
                    return;
                });
                break;
            case 'mixer-update':
            case 'mixer-add':
                var username = entry.users[0];
                MixerApi.getUser(username).then(function(response) {
                    //console.log('Mixer:GetUser', response);
                    if (response.length == 1) {
                        var user = response[0];
                        var stream = {
                            username: username,
                            userLink: 'https://mixer.com/'+username,
                            userImage: user.avatarUrl,
                            streamType: 'mixer',
                            streamId: user.id,
                            streamLink: 'https://mixer.com/'+username,
                            isLive: user.channel.online
                        };
                        //console.log('Mixer', stream);
                        self.postStreams([stream]).then(function(response) {
                            console.log('SignupBot: UpdateMixer', response);
                            resolve();
                        });
                        return;
                    }
                    resolve();
                });
                break;
            case 'twitch-update':
                var streams = entry.entries;
                var userIds = [];
                for (var i=0; i<streams.length; i++) {
                    var stream = streams[i];
                    userIds.push(stream.streamId);
                }
                TwitchApi.getUserStreams(userIds).then(function(response) {
                    //console.log('Twitch:GetUserStreams', response);

                    var live = {};
                    for (var i=0; i<response.data.length; i++) {
                        var stream = response.data[i];
                        live[stream.user_id] = true;
                    }

                    for (var i=0; i<streams.length; i++) {
                        var stream = streams[i];
                        stream.isLive = live[stream.streamId] != undefined;
                    }
                    self.postStreams(streams).then(function(response) {
                        console.log('SignupBot: UpdateTwitch', response);
                        resolve();
                    });
                });
                break;
            case 'twitch-add':
                TwitchApi.getUsers(entry.users).then(function(response) {
                    //console.log('Twitch:GetUser', response);
                    var streams = [];
                    for (var i=0; i<response.data.length; i++) {
                        var user = response.data[i];
                        //console.log('User', user);

                        var stream = {
                            username: user.login,
                            userLink: 'https://www.twitch.tv/'+user.login,
                            userImage: user.profile_image_url,
                            streamType: 'twitch',
                            streamId: user.id,
                            streamLink: 'https://www.twitch.tv/'+user.login
                        };
                        //console.log('Twitch', stream);
                        streams.push(stream);
                    }
                    self.postStreams(streams).then(function(response) {
                        console.log('SignupBot: AddTwitch', response);
                        resolve();
                    });
                });
                break;
        }
    });

    promise.then(function() {
        self.queue = self.queue.slice(1);
        self.checkQueue();
    }).catch(function(e) {
        console.error('Error', e);
    });
}

Last Minute Lore Quiz

After we announced our plans for Battleborn Day, we had a forum member reach out wanting to run a Lore Quiz as well. It was too late to build anything for this, however we did add them to the event hub page and promote them through social media.


The Bro Certificate

The Bro Certificate ended up being a much bigger thing than we had intended. Originally it was just going to be a simple form where the user enters their name and it generates a certificate, but Mentalmars had this idea of turning it into a kind of stamp card that would encourage participation in all the events we had planned.

We ended up creating 12 unique stickers that could be earned through participation in events or by entering the secret codes we were distributing out via social media. We asked Gearbox to include the Gearbox logo sticker with their official support plans for the event. We were also fortunate enough to have Randy Varnell tweet out a special sticker we created based on his avatar.

The certificate page for managing earned stickers and customizing the certificate to be generated. The Bro Certificate, designed by Mentalmars, Beya and Jim Foronda

The Bro Certificate was well received and a number of community members ended up participating in multiple events to try and collect them all. We even had one awesome community member print out and laminate their certificate to put in their wallet!


A Review of Battleborn Day

The community around Battleborn has always been small and had been in a steady decline, so none of the organizers were expecting a huge turnout, but we were happy to see a decent number of folks participate. While SteamCharts has always been used by the haters to validate playerbase numbers, we actually managed to double the playerbase according to SteamCharts for that month. We also received a lot of positive feedback from the community, many who had told us it was the best community event they had ever been a part of.


Art Contest Review

The art contest had 41 submissions, 23 from Twitter, and 18 from the forums. We were only expecting around 10 submissions so it was a pleasant surprise. It's worth noting that more than half of the submissions came from Twitter, which originally wasn't even going to be used for the art contest.


Screenshot Challenge Review

For the screenshot challenge there were a total of 48 submissions, 32 for day 1, 14 for day 2, and only 2 for day 3. There's multiple reasons why there was such a falloff with each daily challenge, for one, participating in any of the challenges would earn you the 'Lil Recycler' sticker but it could have also been a combination of social media reach and what the challenges were on each day.


Loot Hunt Review

The loot hunt had 90 signups and 55 participants who submitted at least 1 drop over the course of the week. 1438 drops were submitted with the average number of drops per user being 26 and the top 3 players each submitted over 100 drops each. I was expecting to get around 30 signups so it was good to end up with 3 times as many.

I saw having a smaller playerbase as a positive as it meant I could test the system I had built and iron out the bugs before applying it to a larger community. There were some minor issues I had to fix during the event, but for the most part it ran quite smoothly, especially given the API backend was running on the lowest Digital Ocean plan. What surprised me though was the number of players of whom English was not their first language. In fact, two of the top contenders were from China and Japan.

I reached out to several players and learned that they had figured out a way to participate despite the rules being in English. Some members of the community had actually translated them and had created a lookup table with the translated item drop names for their non-English friends to use. Others were playing the game in English so they could use the gear search to find drops by name for tagging their submissions. It was quite an eye opener, because it didn't even occur to me that folks would go to those lengths to participate and it is also why all projects I've worked on since the loot hunt now have at least partial language support.

Another issue I discovered was that Twitter seemed to assign a hidden weight to tweets that caused a number of tweets to get missed by the social media bot despite the fact it was checking for new submissions every minute. It turns out you need to upgrade your developer account in order to return all results and while I did put in a request for this access as soon as I became aware of it, it didn't get approved until a few weeks later. I ended up redirecting folks to submit via the Gearbox Forums and the Discord as both of those platforms were successfully logging submissions.

By the end of the event, there was a fairly even spread of submissions from all social media platforms, however Twitter was actually the most popular platform up until the point where it was discovered to not be tracking all submissions. Having the ability to share directly to Twitter from the PS4 / Xbox One likely played a big part in its popularity as more than half of the submissions were from consoles. I'm not familiar with Xbox One, but I know for Playstation 4 it is actually a real pain to transfer screenshots to another device, so supporting the built-in share functionality for Twitter was super important.

To aid with verifying the winners of the loot hunt, I built a tool for viewing submissions for a given user as a gallery. It let me very quickly check for any obvious signs of cheating and I ended up making the tool public.


Caching Issues

One of the issues with caching a link to images hosted on third party CDNs was that if those images got deleted, you have no way of knowing without periodically refreshing the cache on the website. Because the loot hunt required filling out a registration form, I was able to mitigate this problem by letting the user pick an in-game title icon for their profile, which I'd use instead of their social media profile image.

The art contest and screenshot challenge pages were not built with archiving in mind

The art contest and screenshot challenge did not require prior registration to participate, which is why if you go view the submissions now, there will be a number of profile images that are missing because I never implemented a way to retroactively update them whenever the user updated their profile image. It's uncommon for profile images to change over the course of a week, but this is definitely something that needs some kind of substitute or cache refresh check in place especially for archiving purposes.


How Public APIs Could Have Made It Better

One of the goals for this project was to demonstrate how automation can be used to make community events less involved for the players. While I feel like I had successfully created a system that was much more simplified than the Google spreadsheet method the Borderlands community had adopted, there were definitely some things that could have been made even easier if there had been public APIs for Battleborn.

If it was possible to authenticate with Gearbox, it would likely have cut out a lot of user input I'd have to support and store in my website backend as I could just poll their account info and cache it. Instead of getting the user to manually enter their social media accounts, I could look at what third party accounts are linked on their Gearbox account. I'd likely still need to add support for other third parties like Discord, but I'd be able to reduce the amount of user input to almost nothing this way. Accepting user input also opens up your website to security vulnerabilities, which I had to research and implement to prevent malicious input.

Localizing content is a big undertaking and many games these days support upwards of 11 different languages. One of the biggest problems with a lot of community generated guides and resources around games is that they are almost always written for English speaking players. Having the localized in-game text available even as a public share file would be incredibly useful in instances like these. The only other way to realistically generate localized text for large scale content like a gear database would be extracting it from the game files, which can be quite challenging. Since I made more of an effort to localize content for my Destiny 2 projects, I have seen quite a significant increase in users from countries where English is not the primary language, with as much as 20% less website traffic from the US.

One of the trends I saw with submitting item drops was that players would often submit them in bulk. This meant that the timestamps being recorded were often not an accurate representation of when a given item drop had occurred. If there was an API for checking recent in-game drops, I'd be able to automate this entire process and generate an accurate progress log. The downside would be that I'd likely have to change the way the bot would check for updates as even polling 55 players every couple of minutes would be fairly taxing on a single API endpoint. Instead of a bot, the player might then be able to hit a refresh button that would pull down the latest changes from their account.

Another thing I learned from the event was that getting rewarded for participation matters. Once the top contenders reached a certain score threshold, everyone else likely felt they couldn't compete and might stop trying. If there was a way to link players to their Gearbox account, I imagine it'd be a lot more feasible to reward players for participation through an automated rewards system rather than a manual list that a community manager has to go and verify.

If possible, I would have loved to also reward players that reached certain event milestones like finding 10 different items or getting a total of 30 drops during the event, so even if the top contenders are well out of reach, at least players would have some incentive to keep trying. Bungie has actually implemented something similar to this with their Triumphs system in Destiny 2 where each seasonal event has a set of milestone/challenges players can complete to earn rewards. This system is specifically catered to official events, but it would be great if there was a way to do this with community run events via public APIs.

Triumphs for the Festival of the Lost event in Destiny 2

We were very fortunate that Gearbox was able to organize some in-game rewards for the event, however you got these rewards regardless of your participation in the loot hunt. It was an incentive to get on and play, but there was no incentive to put the time in.


Future Events

Using what I learned from Battleborn Day and applying it to community events for other games was always one of the end goals of this project. The loot hunt model can be applied to other games with a loot system and the social media aspect is a good fallback for games that don't have public APIs. Having worked on several popular Destiny projects this year, I have a better understanding of how much strain my website can comfortably handle and have since upped my plan with the influx of new users.

I definitely want to try doing a loot hunt for Borderlands at some point although unlike Battleborn, I don't yet have any of the game data in a format I can use. I also need to add localization support so players won't have to try translating information themselves in order to participate. I'd love to apply this model to a game with an API, however games such as Destiny 2, which has weekly/daily lockout systems make it challenging to build a worthwhile loot hunt event.


Easter Eggs

One of the abandoned ideas we had for Battleborn Day was making a promotional trailer for the event, which was going to be voiced by Jim Foronda. Unfortunately we lost track of the audio file and it didn't resurface until it was well into the week of the event. Despite this, we still wanted to share Jim's work with the community in some shape or form so we decided to add it as a hidden easter egg.

We tweeted out the solution as a series of dodgy directions that were given to Oscar Mike. The story was, he had gotten so lost trying to find Battleborn Day that he was still looking for it a week later. We also gave out hints for players who were getting stuck.

Entering the correct sequence of key presses would trigger a special overlay that starts playing the audio clip and unlocks every sticker for the Bro Certificate.


References

  1. https://developer.twitter.com/en/pricing.html
  2. https://discord.js.org/#/
  3. https://docs.discourse.org/
  4. https://forums.gearboxsoftware.com/t/a-message-from-randy-varnell/1637649
  5. https://forums.gearboxsoftware.com/t/battleborn-day-4-official-events/1642766
  6. https://forums.gearboxsoftware.com/t/battleborn-day-4-tobys-epic-lore-quiz/1642629
  7. https://forums.gearboxsoftware.com/t/introducing-the-discord-lowlibot-gear-sharing/1547958
  8. https://forums.gearboxsoftware.com/t/the-hunt-2017-battleborn-edition/1553438
  9. https://forums.gearboxsoftware.com/t/the-hunt-2017-hosted-by-gothalion-a-twitch-tv-scavenger-hunt/1552238
  10. https://lowlidev.com.au/battleborn/bbday/art-comp
  11. https://lowlidev.com.au/battleborn/bbday/bro-certificate
  12. https://lowlidev.com.au/battleborn/bbday/feed-minrec
  13. https://lowlidev.com.au/battleborn/bbday/loothunt
  14. https://lowlidev.com.au/battleborn/bbday/loothunt-search?username=N1h1l1stb0t
  15. https://lowlidev.com.au/battleborn/gear
  16. https://steamcharts.com/app/394230#1y
  17. https://twitter.com/Horizion_StarZ/status/993773250189647873
  18. https://twitter.com/JimForonda
  19. https://twitter.com/MINREC_Magnus
  20. https://twitter.com/NatsumeRyu
  21. https://twitter.com/beya_exe
  22. https://twitter.com/jythri/status/993003199379464193
  23. https://twitter.com/lowlines/status/998838152977465344
  24. https://twitter.com/mentalmars
  25. https://www.digitalocean.com/pricing/
  26. https://www.polygon.com/2018/1/31/16956254/gigantic-shutting-down-january-update-motiga
Like the stuff that I do? Become a Patron Buy me a Ko-fi