LOOTTOX IS BACK!

Loottox (the ToxiPlays gacha-site) is back and ready for YOUR daily pulls. Try your luck for a chance to listen to an unreleased Toxi song starting at $0/day.

Nice, been waiting for an excuse to pull that off.

If you’re here, you’re either a cyber-nerd, just morbidly curious, or both. Either way, welcome in!

Table of Contents

System Design

Being the first non-static website I had developed in a while, and also being the first webdev I’ve done in months1, I honestly kind of hacked this one together.

I usually like to tell these stories chronologically, but if I do that here, it’ll make for the most wordslop blog post of all time. So, for once, I’ll just go the stream of consciousness route instead.

Server Tools

The server is written in Express v5.2.1 and commonjs. Notable dependencies include cookie-parser, wavesurfer.js, and the default crypto module. That’s genuinely it. I didn’t want to overcomplicate things, and I have been working with Express for around a decade now, so it made writing the serving of HTML files and the execution of code based off of HTTP requests very simple2.

Transaction Flow

When you visit the homepage for the first time, you’ll be taken to my super sick and rad landing page.

Actually, this image makes me want to go on a side tangent that I’m stuffing into a footnote3 because it’s not relevant to transaction flow at all. Anyway.

All of these buttons call a server-side endpoint that, in one way or another, consults the advice of this monolithic helper function:

function generateTransaction(type, req, res) {
    // ...
}

generateTransaction creates a JavaScript object with the following properties:

  • UUID for the transaction itself
  • The type of transaction (basic/standard/ultra)
  • User-Agent and IP associated with the request that triggered this transaction
  • Current time and time of expiry (12 hours from now)
  • A passphrase allowing for /api/downloadSong endpoint access, if and only if the transaction isn’t basic
  • An empty array called items

Undebatably, this singular function is the heart of Loottox. If I messed this up, the whole system would fall apart. That being said, I don’t have a real point of reference for how gacha games or gambling apps work; I’m not knowledgable of any “best practices” that might make their projects cool. I’m merely a fish. So this is what I decided to rock with — if it’s flawed, it just is.

Every song is categorized into “pools” based on their rarity, and those pools are then randomized using the Fisher-Yates Shuffle algorithm.

const pools = {};
for (const song of mediaDictionary) {
    if (!pools[song.rarity]) pools[song.rarity] = [];
    pools[song.rarity].push(song);
}

for (const rarity in pools) {
    const arr = pools[rarity];
    for (let i = arr.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [arr[i], arr[j]] = [arr[j], arr[i]]; // Fisher-Yates shuffle
    }
}

With randomized rarity pools, it is then simply a matter of choosing the correct rate table, calculating RNG weights, picking a random rarity based off the weighted RNG, and then finally choosing a random song’s metadata of that rarity to add to items.

To understand RNG weights, first understand that rates.mjs only stores the percentages as displayed on the home page. The function first uses the guidance of rates.mjs to “build” up to 100%4 and create new ranges for a random percentage to fall in. The standard rate table { common: 50, uncommon: 25, rare: 15, legendary: 10 } will become a range map saying 0–50% is common, 51–75% is uncommon, 76–90% is rare, and 91–100% is legendary.

The range map makes the next step obvious. Based off a random number from 0 to 100 and the range map for this type of transaction, we’ll pick a random rarity as our starting point.

Once we have a rarity pool, picking a random song of that rarity is as easy as pool.pop(), since these pools have already been shuffled. We go through this whole process five times, because the client is expecting five songs.

But there’s one piece of the puzzle missing. Remember “ultra” transactions? They are treated the exact same as a standard transaction, with the sole difference being that the fifth song chosen for an ultra transaction is always going to be from the legendary pool, even if one of the earlier pulls were organically legendary. The fact that it doesn’t check if you already have a legendary song is my little thanks to the user for spending $20.

function pickRarity(rng) {
    const roll = rng * 100;
    for (const { rarity, threshold } of thresholds) {
        if (roll < threshold) return rarity;
    }
    return thresholds[thresholds.length - 1].rarity;
}

function pickFromPool(rarity) {
    const pool = pools[rarity];
    if (!pool || pool.length === 0) {
        // dip into another rarity if this pool's exhausted
        const fallbacks = Object.keys(pools).filter(r => pools[r].length > 0);
        if (fallbacks.length === 0) throw new Error('No songs left in any pool.');
        const fallRarity = fallbacks[Math.floor(Math.random() * fallbacks.length)];
        return pools[fallRarity].pop();
    }
    return pool.pop();
}

for (let i = 0; i < 5; i++) {
    if (type === 'ultra' && i === 4) {
        transaction.items.push(pickFromPool('legendary'));
    } else {
        const rarity = pickRarity(Math.random());
        transaction.items.push(pickFromPool(rarity));
    }
}

Gimme the Loot

All three endpoints will generate a transaction tied to your device/network, and pass the UUID of the generated transaction along as a cookie. Fresh transactions last a full 12 hours, and so does the cookie.

There’s an additional check for basic transactions specifically—if there’s an active transaction tied to your IP or an application like your own, you will be given a cookie that simply points to the existing transaction for however long it has left5.

const ip_transaction = getTransactionByIp(req.ip);
const ua_transaction = getTransactionByUA(req.get('User-Agent'));
let transaction = ip_transaction || ua_transaction || null;

if (transaction) {
    let expiryDate = new Date(transaction.expiry);
    let age = expiryDate - Date.now();
    res.cookie('transactionId', transaction.id, { httpOnly: false, maxAge: age });
    res.status(200).send("OK");
} else {
    const nt = generateTransaction("basic", req, res);
    res.cookie('transactionId', nt.id, { httpOnly: false, maxAge: 43200000 }); // if you're checking, that's 12 hours in milliseconds
    res.status(200).send("OK");
}

After getting the server’s okie-dokie, the client will refresh. Usually a request to / will give the landing page; except if the client has a transactionId cookie, and the server has an active transaction by that ID. When this is the case, Loottox will instead show Gimme the Loot view.

Right after the page finishes loading, the client makes a request to /api/metadata/. The server returns a JSON array of song objects, including their title, description, and rarity. Note that no actual song files are transmitted at this time. The aforementioned three attributes are enough information to power the song info display and allow for The Great Unboxing.6

Once an unblurred song’s title is clicked, its details box expands, and two more calls to the server are made. One is powered by wavesurfer.js and allows for playback of the song, and the other is to /api/getTTML/ that allows for word-synced lyrics display. Now is when the actual song file is retrieved.

For both requests, the server checks not only if you have a valid transaction, but if your transaction even includes the requested song in the first place. If both aren’t true, access is denied. If the client detects that the server has cut it off, it refreshes and clears its own cookies to return to the landing page.

Synced lyrics were almost powered by amll.dev, but their package was very difficult to get working in the browser. In the end, I settled on a custom, lightweight solution that writes to the DOM in pure HTML, JavaScript, and CSS. This pulls from my other projects that involve synced lyrics display: Pandora’s Box and (aptly) TTMLRenderer.

The actual sync data itself is stored on the server and created with these tools in this exact order:

Parsing a TTML is stupid simple once you understand the logistics of (1) fetching an XML file, (2) parsing the tags within it, and (3) converting from MM:SS.sss to seconds with a decimal point9. But I’ll leave that topic for another day, because this is something I could write about for forever.

In Closing

Congratulations! You now know basically everything there is to know that makes Loottox work behind the scenes. It was weird, because this project forced me to actually think about the relationship between a client and a server for the first time since I worked on Scribe Workshop. At the same time, I had a lot of fun working on this, and I do think people who are fans of my music will derive joy from using it. Don’t forget you can give it a spin too! You might as well, after reading this much.

I’m honestly out of things I could say about this beautiful thing, so if you have any specific burning questions you want answered, please feel free to leave a comment and I’ll do my best to answer everything (granted it doesn’t damage the security of that relationship).

You want to know your prize for reading all 1,694 of these words? Footnote dump!!!!!!!! Have fun remembering what all of these out-of-context tangents refer to, nerd.

  1. Before this, it was Covertan from May. Shoutout to the plural community! ↩︎
  2. It… err… didn’t help as much when I was trying to find hosting for this project. But you’ll read more about that soon. ↩︎
  3. Okay, you see those percentages on the home screen? Those aren’t hard-coded or even fetched by the client. These are the genuine numbers used by the server to calculate what songs should be in an individual transaction. It’s exported from a rates.mjs file, but there’s no endpoint to access that information directly.

    “But wait,” you might instinctively ask. “How does the client/landing page even know what rates to show to the client?”

    It’s because of this.

    Do you believe me now when I said this is all hacked together? Please never underestimate the lengths I am willing to go to bodge. ↩︎
  4. The server will even crash if you attempt to boot it up while the numbers in rates.mjs don’t perfectly add up to 100, because it will break the weighted randomization. I added the crash.

    “But wait,” you might ask again. “Why do you even need this?”

    Because I’m genuinely bad at math. You won’t believe how many times writing this crash in has saved me while tweaking the numbers to things that just vaguely look right. Numbers are weird. ↩︎
  5. This is honestly a poor solution, because user agents are not unique in the vast majority of cases. It came down to a trade-off situation. I’d rather have multiple people share the same transaction as opposed to one person being able to game the system because I’m only checking for IP. ↩︎
  6. In other words, just clicking on blurry song titles until they aren’t blurry anymore. But like, The Great Unboxing sounds way cooler. That’s the canon name now. ↩︎
  7. It turns ” ” into “\ \” and “-” into “-\”. Can you imagine how annoying that’d be to do manually? Mind you, this was years before I got into Spicy Lyrics and learned about Lyrprep, but I still trust in my robust tool. ↩︎
  8. Similar to, not exactly. Important distinction here! It does not output a TTML file that can be uploaded directly to Apple Music. It has its own extensions, such as songwriter tags, itunes:key, etc. For the purposes of Loottox, I ignore all of the extra metadata and don’t even supply it when creating syncs. Songwriters have proven very useful, however, for projects such as TTMLRenderer and TextFlag. ↩︎
  9. …because JavaScript only tells you that you are “21.3294824174 seconds” into an audio file, and TTML timestamps need to be converted to that format to be useful. ↩︎

Pages: 1 2

Comments

2 responses to “LOOTTOX IS BACK!”

  1. autumns Avatar

    big fan of this toxi toxiplays toxisue

    1. Trixie Cabi Avatar

      Thank you autumn agowers autumns

Leave a Reply

Your email address will not be published. Required fields are marked *