Mania Guesser

Discord song-guessing game for osu!mania

13 min. read

What is this?

Mania Guesser is a Discord game bot where players encounter random songs and BG images of osu!mania maps. The player earns points by guessing a song’s name correctly (similar to pokecord bot). This bot was made purely for fun, and it so far is in 20+ Discord servers. You can find the repository here.

uwu

General Commands

  • m!help: displays a list of commands and troubleshooting for this bot.
  • m!setchannel: sets the Play Channel for this bot. You must have a role called Mania Guesser to use this command.
  • m!info: general bot info + invite link.
  • m!top: shows the top players for this server and globally.
  • m!stats: shows your stats for the mania guessing game.
  • m!songs <page-num>: shows your current collection of songs.
  • m!ranks: view a list of unlockable ranks.

Game Commands

  • m!guess <song-name>: guess the current song. You can use this during ‘encounters’.
  • m!hint: reveals a hint for the current encounter.
  • m!play: starts a new song encounter.
  • m!skip: skips the current song encounter.

Secret Command

  • m!uwu Posts an image of the mascot’s uwu expression.

Setup (for server owners)

  1. Invite the bot to your discord server https://discordapp.com/oauth2/authorize?client_id=642470359354048527&scope=bot
  2. Create a Mania Guesser role and give it to anyone that wishes to have access to the m!setchannel and m!removechannel command.
  3. Set the Play Channel m!setchannel (this is where you will see the bot spam.)

Setup (for developers)

  1. Make sure you have the latest version of npm installed on your server machine. Current Version: 6.12.0.
  2. This bot also requires you to make a real-time database from firebase (but you can modify the code to use a different type of database.) The bot should also automatically populate your database if you plan on using firebase.
  3. clone the repository to the host server git clone http://github.com/staravia/mania-guesser
  4. cd to the project directory and install node packages npm install
  5. create a private.js file
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // Example private.js file

    // Discord Bot Secret
    module.exports.BotSecret = "---------------------";
    // Firebase Config
    module.exports.FirebaseConfig = {
    apiKey: "---------------------------------------------------",
    authDomain: "---------------------------------",
    databaseURL: "--------",
    projectId: "----------",
    storageBucket: "----------",
    messagingSenderId: "----------",
    appId: "----------",
    };
  6. execute node index.js

song-data.json

If you plan on creating your own songdata.json file (for example, you would want to do this if you plan on creating a osu!std version of this bot), take a look at this repository: https://github.com/staravia/osumania-to-json

Program.cs (External tool)

There are currently over 1000+ songs that the player can unlock. I made a small program that grabs files from my osu! database, and exports the data onto a .json. The .json is used as a library for the game. It uses the holly.osu-database-reader library from nuget to read the osu!.db file directly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Program
{
/// <summary>
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
private static List<Song> ParseFiles(string path)
{
var output = new List<Song>();
var db = OsuDb.Read(OSU_PATH);
var maps = db.Beatmaps.Where(x => x.GameMode == GameMode.Mania && ( x.CircleSize == 4 || x.CircleSize == 7 )).ToList();
var prev = -1;

foreach (var map in maps)
{
var title = map.Title.ToLower();
var artist = map.Artist.ToLower();

// Ignore prev mapset
if (map.BeatmapSetId == prev)
continue;

// Ignore non Ranked or loved maps
if (!(map.RankedStatus == SubmissionStatus.Ranked || map.RankedStatus == SubmissionStatus.Loved))
continue;

// Ignore packs/mapset/memes/compilations
if ( title.Contains("pack") || title.Contains("mapset") || title.Contains("collection") || title.Contains("meme") || title.Contains("compilation"))
continue;

// Ignore Various Artists or unknown
if (artist.Contains("various") || artist.Contains("unknown") || artist.Length <= 1 || artist == "zen" || artist == "zzzzz")
continue;

// Ignore weird ID
if (map.BeatmapId == 0 || map.BeatmapId == -1)
continue;

prev = map.BeatmapSetId;
var song = new Song()
{
Artist = map.Artist,
Title = map.Title,
Id = map.BeatmapSetId.ToString()
};

output.Add(song);
Console.WriteLine($"{song.Artist} - {song.Title} [{song.Id}]");
}

return output;
}
}

public class Song
{
public string Artist;
public string Title;
public string Id;
}

index.js

This script handles all game instances. All communications with the firebase database is established here. There are also eventListeners which handle any user commands that require access to the cached data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// ...
const firebase = require("firebase/app");
// ...
const Ranks = require("./constants").Ranks
const DataField = require("./constants").DataField;
const Game = require("./mania-guesser.js");
const EventEmitter = require('events');
var contents = fs.readFileSync("./song-data.json");
var songData = JSON.parse(contents);
var instances = [];
var users = [];
var guilds = [];
// ..
// Initialize list of users. This is used to cache the player's data in the server which
// will then be used to update the database in a different server.
function initUserQuery(){
var userquery = firebase.database().ref("users").orderByKey();
userquery.once("value").then(function(snapshot) {
snapshot.forEach(function(childSnapshot) {
console.log("[USERQUERY INIT]: " + childSnapshot.key + " added to query.");
if (users[childSnapshot.key] === undefined){
users[childSnapshot.key] = childSnapshot.val();
if (users[childSnapshot.key] === undefined){
users[snapshot.key] = { correct : 0, total : 0, hints : 0, songs : {"0" : 0}};
}
}
});
});
}
// ..
// Some other methods
function initGuildQuery(){}
function handleError(e, msg = null){}
function saveUserData(user) {}
function saveGuildData(guild){}
function increment(user, val, song = -1){}
function setPresence(){}
function handleGuildAttach(guild){}
function handleGuildDetach(guild){}
function viewTop(msg){}
function viewStats(msg){}

// Initialize Query
initUserQuery();
initGuildQuery();

// Bot Ready
client.on("ready", (member) => {
setTimeout(function() {
var servers = client.guilds.array();
for (var i in servers){
console.log("[CLIENT ON]: Initialized Game in guild: " + servers[i].name + " id: " + servers[i].id);
if (guilds[servers[i].id] === undefined)
instances[servers[i].id] = new Game(songData);
else
instances[servers[i].id] = new Game(songData, client.channels.get(guilds[servers[i].id]));
handleGuildAttach(instances[servers[i].id]);
}
setPresence();
console.log("[CLIENT ON]: All Guilds Initialized");
}, 5000);
});

// Bot Joined Server
client.on("guildCreate", (guild) => {
try {
console.log("[JOIN]: Initialized Game in guild: " + guild.name + " id: " + guild.id);
instances[guild.id] = new Game(songData);
handleGuildAttach(instances[guild.id]);
setPresence();
}
catch (e){
handleError(e);
}
});

// Bot Got Kicked
client.on("guildDelete", (guild) => {
try {
console.log("[LEFT]: Stopped Game in guild: " + guild.name + " id: " + guild.id);
handleGuildDetach(instances[guild.id]);
delete instances[guild.id];
setPresence();
}
catch (e){
handleError(e);
}
});

// User Sent Message
client.on("message", (msg) => {
try{
if (instances[msg.guild.id] === undefined || instances[msg.guild.id] === null){
console.log(`[WARNING]: Uninitialized server in guild: ${msg.guild.name} id: ${msg.guild.id}`);
return;
}
instances[msg.guild.id].handleOnMessage(msg, users[msg.author.id]);
}
catch (e){
handleError(e, msg);
}
});

// Log-in
client.login(BotSecret);

mania-guesser.js

All game logic is handled here. Since every Discord server has its’ own game, this class will be created every time it joins a discord server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// ...
const Ranks = require("./constants").Ranks;
const DataField = require("./constants").DataField;
const Discord = require("discord.js");
const EventEmitter = require('events');
const Commands = require("./constants").Commands;
// ...

module.exports = class Game {
handleOnMessage(msg, userData){
if (msg.author.bot) return;
if (!msg.content.startsWith(Prefix)){
this.handleEventCounter(msg.channel, msg.author);
return;
}
var cmd = getCommand(msg.content.substring(Prefix.length));
var tag = getTag(msg.author.id);
switch(cmd){
// ...
// Game Logic
// ...
}
}

handleEventCounter(channel, author){
// Encounters only can happen when there is a Play Channel
if (this.playChannel == null)
return;
// Make sure there is currently not an encounter before creating one
if (this.currentSong != null && this.encounterDate != null){
if ((new Date() - this.encounterDate)/1000 < TimeOutMax)
return;
else {
sendMessage(this.playChannel, "Time out! Took too long to guess the song.");
this.endEncounter();
}
}
this.currentCount++;
// If current count exceeds the target count then the enxounter event will happen.
if (this.currentCount > this.encounterCountTarget){
this.startEncounter(this.playChannel);
}
}

handleGuess(content){
var correct = "";
var guess = "";
var spaceCount = 0;
var letterCount = 0;
// Correct Answer
for (var i=0; i<this.currentSong.Title.length; i++){
var char = this.currentSong.Title[i];
if (charIsLetter(char)){
letterCount++;
correct += char.toLowerCase();
}
if (charIsBracket(char))
break;
if (char == ' '){
if (letterCount >= 3)
spaceCount++;
if (spaceCount >= 2)
break;
letterCount = 0;
}
}
// Guess
for (var i=0; i<content.length; i++){
if (content[i].toLowerCase() != content[i].toUpperCase()){
guess += content[i].toLowerCase();
}
}
return guess.substring(0, correct.length) == correct;
}
startEncounter(channel){
this.totalHintsGiven = 0;
this.currentSong = this.songData[Math.floor(Math.random() * this.songData.length)];
this.encounterDate = new Date();
sendMessage(channel, "**Encounter! **\nType `m!guess <songname>` to guess the osu!mania song! Type `m!hint` for a hint!\nSong: " + getSongImage(this.currentSong));
sendMessage(channel,getHintBlock(this.currentSong, this.totalHintsGiven))
}

endEncounter(){
this.currentCount = 0;
this.encounterCountTarget = Math.floor(Math.random() * CountDelta + CountMin);
this.currentSong = null;
this.encounterDate = null;
}

// ...
// Generate hint with given title and total hints given
function generateHint(title, hints){
var output = "";
var total = title.length - Math.floor(title.length * (0.6 * hints/MaxHints + 0.1));
var hint = [];
// Generate no hint if title length is less than 2
if (title.length <= 2){
for (var i=0; i<title.length; i++){
output += "_";
}
return output;
}
// Generate Random pos
for (var i=0; i<title.length; i++){
hint.push(i);
}
for (var i=0; i<total; i++){
if (hint.length == 0) break;
var rand = Math.floor(Math.random() * hint.length);
hint.splice(rand,1);
}
// Generate Hint
var nested = false;
for (var i=0; i<title.length; i++){
var char = title[i];
var cont = false;
if (charIsBracket(char))
nested = true;
else if (charIsBracket(char,true))
nested = false;
for (var j=0; j<hint.length; j++){
if (i==hint[j]){
output += char;
hint.splice(j, 1);
cont = true;
break;
}
}
if (cont == true) continue;
if (charIsLetter(char) && nested == false)
output += "_";
else
output += char;
}
return output;
}
}