Good Luck Out There

QuartzGuard (Discord Bot) & Code Discussion

What is QuartzGuard

Skip to Theory Of Operation for the nerdy stuff

QuartzGuard is a Discord bot that I made over the course of two weeks, entirely written in Python. The name is just the first thing I could come up with, Quartz (me) and Guard, as in guarding the Discord channel that I made just to create this bot. The original intention was to automatically check for player deaths, new players, and to announce them into a private Discord server of just me and the bot to notify me. However, I thought it would be so cool if I could have player deaths show up on the website, as well as display any player who joined the server, and thus I wanted to be able to share with anyone who would want to join the server, especially with all the work I put into it.


Why it took so long

This version of the bot is actually a rewrite of the first week. I spent the first week with noble intentions, but then got impatient and committed the computer sin of copying and pasting code, as after a couple of days, I wanted to be finished, as I did not want to open the server until the bot was done. I had achieved getting player deaths and announcing them by myself, but in order to upload to my website, I needed to programmatically create HTML and utilize the REST API built into WordPress. And while I am familiar with REST, HTML is not my strong point. So after about four days of Stack Overflow and copying and pasting, my code no longer worked, and since I did not write it, I had no clue how it worked. Programming is a hobby for me that I have done off and on for the last 6 years. I would consider myself at the intermediate level, and while not the most impressive I’ve done, this has been the biggest program I’ve ever made. Yet, I could not be proud of it, while it was a bigger program, I didn’t write most of it, and I had no idea what it did. So about 6 days in, I deleted everything and started over.


In the first version of the program, I would open the log file that the Minecraft server .jar creates and would read every new line in order to get any new player deaths. This was first achieved by hiring someone on Fiverr to create a Minecraft plugin to log every death to a text file and write the output to the console. Now, paying someone to create a plugin, as I’m talking about a programming project, might sound a bit odd, but I do not know Java or programming Minecraft plugins. So at first, I was going to read the generated file, but I felt that it would be faster to read the log. I even used the same method to check for any new players who joined. It was fast, but it was also getting a bit more difficult to maintain. For instance, I had to check for when the server would do its nightly restart, and doing so required checking for the line “[MoonriseCommon] Awaiting termination of I/O pool for up to 60s…” and then waiting for a new server log to generate by constantly checking the modification time for a new log. Another issue that came up was that I had to also check that the log was not a chat message, as if anyone were to type the termination line into chat, it could stop execution, or possibly be weaponized to spam the Discord server and the website with fake player deaths.


Theory of Operation

Player Obituaries:

The currently utilized method to check for player deaths in this version is to create a copy of the generated player deaths from the plugin I ordered, also known as LoggerOfDeath, hash the contents of both files, and compare them.

@tasks.loop(seconds=10)
async def get_player_deaths(self):
   with open(self.player_deaths_original, "rb") as data:
       original_hash = hashlib.md5(data.read()).hexdigest()


   if self.death_compare_hash != original_hash:
       with open(self.player_deaths_compare, "rb") as data:
           self.death_compare_hash = hashlib.md5(data.read()).hexdigest()


       with open(self.player_deaths_original, "r", encoding="utf-8") as file:
           original_content = file.readlines()
       with open(self.player_deaths_compare, "r", encoding="utf-8") as file:
           compare_content = file.readlines()

I had also gotten the source code when I paid for the plugin and learned the bare minimum Java to change the death file text from a human-readable “Oct 24, 2025 [05:52:18]→ Quartz_Candy (48886f03-6271-4282-8bc9-ae9d683980bc) ‘Quartz_Candy fell from a high place.’ [world: 104 64 216]” to a more computer-friendly JSON:

{
   "48886f03-6271-4282-8bc9-ae9d683980bc":{
      "date":"Oct-29-2025",
      "deathMessage":"Quartz_Candy fell from a high place",
      "location":"[world_the_end: 8, 85, 1]",
      "time":"20:19:32",
      "player":"Quartz_Candy"
   }
}

This allowed me to stop parsing text and finding the substrings between it and to get the most important information quickly, by referencing the key. Creating the HTML in this instance was surprisingly easy, as all I wanted was the player’s name, death, time of death, and skin. Even getting the player statistics wasn’t too hard, but unfortunately, it had a monster of an if/else if statement to comb through it all.

def _create_stats(stats_file):
   stat_content = json.loads(stats_file)["stats"]


   general_stats = []
   mined_stats = []
   items_broken_stats = []
   items_crafted_stats = []
   items_used_stats = []
   items_picked_up = []
   items_dropped_stats = []
   entities_killed_stats = []


   for stat_type, values in stat_content.items():
       if stat_type == "minecraft:custom":
           for key, value in values.items():
               clean_key = key.replace("minecraft:", "").replace("_", " ").title()
               if "time" in key:
                   general_stats.append([clean_key, _get_mc_time(value / 20)])
               elif "aviate" in key:
                   general_stats.append(["Elytra", f"{value/100:.0f} blocks"])
               elif "cm" in key:
                   general_stats.append([clean_key.replace(" One Cm", ""), f"{value/100:.0f} blocks"])
               elif "damage" in key:
                   general_stats.append([clean_key, f"{value * 0.1:.1f} hearts"])
               else:
                   general_stats.append([clean_key, value])


       elif stat_type == "minecraft:mined":
           mined_stats += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


       elif stat_type == "minecraft:broken":
           items_broken_stats += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


       elif stat_type == "minecraft:crafted":
           items_crafted_stats += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


       elif stat_type == "minecraft:used":
           items_used_stats += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


       elif stat_type == "minecraft:picked_up":
           items_picked_up += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


       elif stat_type == "minecraft:dropped":
           items_dropped_stats += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


       elif stat_type == "minecraft:killed":
           entities_killed_stats += [[k.replace("minecraft:", "").replace("_", " ").title(), v] for k, v in values.items()]


   return {
       "general": sorted(general_stats, key=itemgetter(0)),
       "mined": sorted(mined_stats, key=itemgetter(0)),
       "broken": sorted(items_broken_stats, key=itemgetter(0)),
       "crafted": sorted(items_crafted_stats, key=itemgetter(0)),
       "used": sorted(items_used_stats, key=itemgetter(0)),
       "picked_up": sorted(items_picked_up, key=itemgetter(0)),
       "dropped": sorted(items_dropped_stats, key=itemgetter(0)),
       "killed": sorted(entities_killed_stats, key=itemgetter(0))
   }

While quite the monster, I really do not know how it could be improved. After getting a list full of dictionaries, I was able to create an HTML super easily:

def create_table(data, column=4):
   table_html = ['<table class="customTable has-fixed-layout" style="width:100%; table-layout:fixed; border-collapse:collapse;">']


   entry_counter = 0
   row_html = ""
   for stat in data:
       # rewrite to be just {stat}
       row_html += f'<td>{stat[0]}: {stat[1]}</td>'
       entry_counter += 1


       if entry_counter >= column:
           table_html.append(f'<tr>{row_html}</tr>')
           entry_counter = 0
           row_html = ""


   table_html.append('</tbody></table><hr>')


   return "".join(table_html)

The hardest part about the table is that it would look fine on a desktop but be unreadable on mobile. I was able to fix this by unfortunately learning some CSS on top of the HTML. Updating the graveyard was a different story. I wanted to have the graveyard sorted by most recent deaths. I had originally tried the sin using Python’s re library for regex searching HTML, but instead got smart and tried using BeautifulSoup. Unfortunately, I could not achieve this, and I will not be sharing the code in this post (but it is on GitHub) as I did not write it. (But I did write the async WordPress class, more on async later)

Check out https://quartzcandy.com/graveyard/smilehuman/ for a player obituary example

Unique Players

Finding out unique players was much simpler than I had originally thought. The method I ended up using was checking every .JSON file in the stats server folder and annotating the UUID after it has been processed for the player list (same story as above on the HTML code)

@tasks.loop(seconds=120)
async def check_for_new_players(self):
   with open(self.unique_players_file, "r", encoding="utf-8") as players:
       unique_players = players.readlines()


   stat_files = os.listdir(self.stats_dir)
   if len(unique_players) < len(stat_files):
       for stat in stat_files:
           if stat not in unique_players:
               uuid = stat[:-5]
               player = self._get_username(uuid)
               self.logger.write("info", f"Found new player {player} | {uuid}")

An interesting thing with this implementation is that it will ignore most server scanner bots. These bots typically perform a disconnect instead of a full join, so a new file will not be created for them.

Reactions

This one is mostly for enjoyment and was fast to write, but when a player dies and their death gets announced on Discord, the bot will automatically react with a corresponding emoji relating to the death. I will not show all of them as there are 75, but check out cogs/reactions in the GitHub for the full list:

self.death_emojis = {
   # death messages
   "pricked" : "🌵",
   "cactus" : "🌵",
   "drowned" : "🏊",
   "kinetic" : "🧚",
   "blew up" : "💥",
   "blown up" : "💥",
   "Intentional Game Design" : "🛌",

Things I wanted to Implement

I had originally wanted to have some more server admin-related functions, such as ones that privately message me about anti-cheat violations, x-raying, and any hateful rhetoric. And while I still could add them, since the Python implementation allows hot swapping, I was reaching a point of feature creep and was constantly pushing the deadline further. I will also expand more on this in the “Will I do it again?” section.

Docker

A cool thing about this project is that I now have a reason to have a “production-ready” program set up in a Docker container that will restart on crashes or VPS reboots. I had only started learning how to use Docker when creating this site and hosting the Minecraft server, so it was fun to get something I made to work with it. These files are in the GitHub for anyone to host their own instance of QuartzGuard (although the code will not work for you without the LoggerOfDeath plugin or your own WordPress site), but you will need to create your own Discord dev token.

Problems

Again, I reiterate that the bot needs more work. For example, it needs better error handling. Some examples include FileNotFound errors that were ever so present when moving the bot from its testing on my machine and a localhost server to my VPS and real server. This was resulting from improper config files; I had to tell the program where to look, and they did not exist. Another big one is that while the LoggerOfDeath plugin should never write any non-JSON string in there, it’s always a possibility of a malformed JSON string or bad data making its way in:

for change in set(original_content).symmetric_difference(set(compare_content)):
   json_string = json.loads(change)


   # feel like there has to be another way, but trying to get first key with just json would not work
   uuid = list(json_string.keys())[0]
   player = json_string[uuid]["player"]
   death_msg = json_string[uuid]["deathMessage"]
   time_of_death = json_string[uuid]["time"]

A huge issue is that the graveyard will only work if there is a player already on the page. While I have it working on the players page without such requirements, I spent so long working on this that since it works and I would only need to use that functionality the first time someone died, I am doing the wrong answer and keeping a sacrificial player. In an effort for transparency, SmileHuman is an alt account that holds that sacrificial slot.

The next issues are mostly complaints, but a problem I had that I could not solve for hours was that my separate Discord functional files (called cogs) would not load more than two, and I have a total of four. This turned out to be an issue with Python caching the bytecode of the unchanged cogs and would, for some reason, not load the files, despite Python recognizing they were present. I still have no idea why this was an issue, and hopefully, some nerd can explain to me.

# If only loading two files, delete the __pycache__ folder and it loads all cogs
async def load_extensions():
   for filename in os.listdir("cogs"):
       if filename.endswith(".py"):
           ext = f"cogs.{filename[:-3]}"
           try:
               logger.write("info", f"Loading extension: {ext}")
               await bot.load_extension(ext)
           except Exception as e:
               logger.write("error", f"Failed to load {ext}: {e}")

Lastly, when using discord.py, you write (most of the) functions to be performed asynchronously. If you do not know what that means, do not worry. I do not either. But I can explain how it felt to write. Imagine you want to achieve a goal, and you give 10 specific tasks to someone in order to achieve it. Now imagine you hire a manager who hires 10 more people to each do 1 of the 10 tasks, but they sometimes do not talk to you or just decide to go home without telling anyone, and nobody picks up where they left off until the next day, where they might try again.

Would I do it again?

Knowing what I know now, would I do this all again? No. At least not how this is implemented. Just know there is a non-negligible chance I will perform a third rewrite. However, in this version, I would skip the Discord bot doing most of the heavy lifting. It should not be generating HTML, reading the same .json files every two minutes, and posting to my website. Instead, I would have it solely announce the deaths and link to the obituaries.

To skip the HTML generation from my end, I would create a custom endpoint on my website to take a POST request. These requests would ideally be performed by a plugin running on the server. This way, it can hook directly into the Minecraft death event, skipping any FileNotFound or JSON issues. Same with finding unique players, when a new player logs in, it could post to the players’ page.

Leave a Reply

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