⚠ UNDER CONSTRUCTION ⚠ this personal prototypical project is perpetually a work in progress ⚠ UNDER CONSTRUCTION ⚠
← back to all posts

Writing Discord Integration for UNBEATABLE


Hello Internet! What an overused phrase. I mean, CGP Grey uses it, I'm pretty sure Game Theory uses it, I feel like 'Hello Internet!' has become moreso sounds-that-one-understands-as-a-greeting than the actually words Hello and Internet.

Not even a minute in and already off topic.

Hello readers! Recently I started work on better-unbeatable-rpc, a project to make UNBEATABLE - the 2025 rhythm-adventure where music is illegal and you do crimes - display a nice discord status when playing. This would require at least three things:

  1. A way to extract and send the information from UNBEATABLE regarding game state during play,
  2. An always listening daemon to capture this information,
  3. A socket to send this information to discord.

I have, to put it bluntly, never modded a game before. Like, for real. The closest I've ever got to 'modding a game' is watching screenshares of Minecraft fabric development, which isn't exactly much. In order to fufill step one, get information such as track name, artist, charter, current difficulty, player PB, etc, you'd need a mod. UNBEATABLE is made with Unity, a game engine and development platform that primarily uses C#. I don't know C#.

Already this is looking like a massive clusterfuck, but I really wanted to get this project off the ground, so I set some ground rules. First and most importantly, do most of the code in not C#. I chose Go as I like it, I know it, and it can more-or-less be ran anywhere (providing that you have different cases for different OS system calls, of course. This isnt java. Put a pin in this.) Working in Go also meant that almost all of the code could be easily written and understood by myself, without needing relience on - god forbid - any AI agents. If this project were to be in C#, the way one probably would've scaffolded it, that wouldn't be the case. And therefore it wouldn't be my project.

I think an entire blogpost about AI is warranted at some point. I don't want to repeat the same points as everyone else, though. I'd need to provide new and unique insight.

I. Data.

Here's your good news for the week, unbeatable is a Mono build of Unity. A Mono game ships its own code as ordinary .NET assemblies, mostly one file called Assembly-CSharp.dll, and .NET assemblies decompile back into something very close to the original source. This means that, among many other things, you don't really need to know C# to read the game. You need to know enough to.. squint at it.

For the mod itself I'm using BepInEx5, the usual and standard loader for Unity games. Harmony, a runtime method-patching library that comes with BepInEx, lets you attatch your own code to the start or end of any of the games methods without touching the game's files. Mint!

I ended up needing this in five areas,

  • When a song starts, and
  • When a song ends, and
  • When the results screen opens, and
  • When the game calculates star rating, and
  • One to stop the game's own Discord integration from starting at all.

A lot of the state I wanted - which mode you're in, which song, whether the pause menu is open - lives on a static class D-Cell named JeffBezosController. This made me giggle every single time, even as i write this. I have nothing more to say.

From there, a small component polls the game at a rate of twice per second and, should something have changed or should it have been ten seconds (so we know the connection hasnt died), sends it's retirived information to the daemon.

A message looks like this:

{"v":1,"state":"arcade","song":"RE-TUNED","artist":"peak divide ft. Rachel Lake","charter":"re-kouned","difficulty":"UNBEATABLE","level":20,"highScore":1166540,"rank":"S","stars":8.703}

It goes over UDP to 127.0.0.1, port 47820. No connection to set up and nothing to retry. If the daemon isn't listening, the packet goes nowhere and the game literally doesn't notice. By extension neither can I. The JSON is built by hand with a StringBuilder, which nobody would recommend, but it saved dragging a JSON library into someone else's game.

This was actually, genuinely, quite an easy process once you've gotten started and you're motivated. There's two things I want to mention, though.

Pausing the game: On pause, the game has an isPaused flag, which looks like the obvious thing to check. Except it's also true on loading screens, which is not obvious at all. This means that the moment you finished a song, yet before you could see your results, a 'loading screen' of less than half a second that doesn't even appear as a loading screen on my hardware - would announce that the player has paused their game. Yugh. Turns out the fix was really easy because there's also a flag called inPauseMenu, which I should have been using.

Star ratings: The method GetPlayerRankreturns your star rating. However, due to circumstances that need not concern us around this blog post, it also writes your states and can complete challenges as a side effect. (Why call it Get, then?). Quietly ticking off someone's challenge just to put a number on Discord felt like a bad trade, so I made the executive decision to just.. not call this method. Instead I wait for the game to call it itself, read the result on the way out, and save the last value so there's something to show before the game gets around to asking again.

Final count of 561 lines of C#. Not nothing, but not a project either. Lots of docs, lots of looking at other BepInEx mods, and - because I'm an honest man - a sprinkle of debugging with an agentic partner.

II. Proton.

If you're unaware, Proton is a translation layer for x86 Windows games to get them running on linux. It's Valve's (and therefore Steam's) official build of Wine. This means that, again among other things, the game thinks it's on Windows.

On Windows, Discord listens on a named pipe. On Linux, discord listens on a Unix socket. I'm sorry if you don't know what that means, all you need to know is that they're different.

A Windows program inside Wine goes looking for this pipe and upon not finding it, promptly gives up. This is why many, many integrations do nothing on Linux; including this one. It's also why I started this project as Linux only at first - I actually had no idea if unbeatable had a rich presence for Windows or not. I'm the only person I know who plays the game, so I had only ever seen my basic one that I had to manually enable from the 'Registered Games' tab of the discord native app... Yuck. Considering the response I got on twitter, though, it seems as if unbeatable doesn't have working RPC on any platform - but i'm getting ahead of myself.

All of this is to say that UDP on localhost crosses this boundary without any fuss, because Wine passes network calls through to the real Linux networking stack. The game throws its packet at 127.0.0.1, a native Linux daemon catches is, and the daemon talks to the linux socket. The split I'd have done anyway to avoid C# actually made this process an order of magnitude easier, so that's good. It's somewhat the only reason this works on Linux at all.

Getting BepInEx to load under Proton is it's own small adventure. It hooks in by pretending to be winhttp.dll, and Wine has to be told to load that copy instead of its own. You can do it with a launch option, or by editing the registry in the game's Proton prefix which is what the installer offers to do. Wine writes that registry file with its own escaping (which I got completely wrong the first time), and Steam's "delete local content" wipes the prefix so the fix quietly disappears and you have to run the installer again. Fun.

III. Daemon.

The deamon is a standard Go program that sits on port 47820 and waits. Every message gets parsed and its schema version is checked, juuuuust in case I decide to change it in future. Future-proofing is something I've always taken very seriously in my code, even though I don't think I've ever used one of these features.

It only connects to Discord while the game is open (alive). The first message opens the connection; and a closed message (or 30 seconds without a heartbeat) clears your status and disconnects. If Discord isn't running, it retries with a backoff that starts at a second and tops out at thirty. And Discord doesn't like being told things too often, so the daemon compares what it wants to show against what it last sent, only sends when they differ, and never more than once every four seconds. Can you tell this isn't my first rodeo?

Launching the game every time I wanted to test a status message got old within about an hour, so the repo also has fakegame: a teeny tiny Go program that sends the daemon what the plugin would. Menus, a song, a pause, results, whatever. Most of the daemon was built against that rather than the real game, but it all worked out in my testing, soooooo we're okay?

Settings for this daemon live in one JSON file, polled every other second. Polling instead of a proper file-watching library is less clever, but it's 'standard library' only and behaves the same on every OS. Should the file be broken the daemon should never be brought down, it just keeps whatever it had before the break and reports the error. There's also a read-only status endpoint on port 47821 for the settings app to ask what's going on which refuses any request that isn't addressed to localhost because otherwise a webpage could reach it through DNS fuckery. This was the best way for me to implement a 'status' tab on the frontend that showed what the daemon was up to. (The 'daemon' tab, which shows the journal, just reads it directly.)

IV. Discord.

There's no Discord library in here; the protocol is small enough to write yourself. Seriously! If you've never built anything with Discord before, they have really nice developer tools (their OAuth has been my default for literally half a decade.) Every message is an 8-byte header - an opcode and a length, both little-endian 32-bit integers (if you care) - followed by JSON.

buf := make([]byte, 8+len(payload))
binary.LittleEndian.PutUint32(buf[0:4], uint32(op))
binary.LittleEndian.PutUint32(buf[4:8], uint32(len(payload)))
copy(buf[8:], payload)

You send a handshake with your application's client ID, wait for Discord to say READY, and from then on send SET_ACTIVITY commands each tagged with a random number-used-once so you can match up the replies. Discord occasionally pings; you pong!

The annoying part and, to be fair its not really Discord's fault, is where "different cases for different OS system calls" comes back to collect. Remember when I told you that this isn't java and to put a pin in it?

On Linux the socket is called discord-ipc-0 (up to -9), but which folder it's in depends on how you installed Discord: the runtime directory for a normal install, somewhere under app/ for Flatpak, somewhere else again for Vesktop, and somewhere else again for Snap. This means that, with honestly quite little other option, this daemon has to check all of them. On Windows it's a named pipe, \\.\pipe\discord-ipc-0, and you can't check whether a pipe exists without opening it, which uses it up, so the Windows version just tries them in order. This, sucks! Both ways! It isn't really an enjoyable system on either Linux or Windows. But it works, and across Vesktop too. Microsoft's go-winio handles the pipes and is the daemon's only real dependency.

Aside: have you ever heard of Go build constraints? At the very very top of your file, before package main or similar, you can include a line that tells the compiler "hey, only use this file if you're building for this OS." It's a little bit like a shebang as it starts with the comment declarator (//). dial_unix.go starts with //go:build !windows, but dial_windows.go doesn't need the line at all: Go treats _windows at the end of a filename as the same constraint. (_unix isn't one of the names it recognises, hence the line.) Both files provide the same function, and the compiler only ever sees the one for the platform it's building. Neat! IntelliJ unhelpfully gives you a warning when you're working in a file that will be ignored by the compiler, as if that wasn't obvious. If you follow me on my private twitter you'll know that a good third of my tweets are complaints about IntelliJ's intellisense, or whatever they call it.

V. Release.

v0.1.0 was tagged on 1 October. Two weeks and 70 commits later it was on 0.3.2, and had picked up a few things nobody asked for:

  • Styles. detailed is the default. spotify puts the title on top and the artist underneath. osu copies osu!'s own status format, artist, title, charter and difficulty, with "Doing crimes" in place of osu!'s "Clicking circles".
  • Custom styles, with a little template language and a drag-and-drop editor, so you can build your own and share them as text.
  • A settings app, built with Wails: Go underneath, a web page on top. Sorry!
  • Installers for Linux (a systemd user service) and Windows (start at login), plus update checks and a "copy diagnostics" button for bug reports.
  • Windows support, built and tested entirely from Linux bar opening the game one time on one windows boot, which the README apologises for in advance.

By the end, it's roughly 3,700 lines of Go (5,600 with tests), 1,900 lines of JavaScript, HTML and CSS for the settings app, and the same 561 lines of C#.


better-unbeatable-rpc is on 0.3.2 and licensed under GPL v3. Linux is the best-supported platform; Windows works but is labelled experimental. If you play UNBEATABLE and use Discord native or Vesktop, give it a go - and if it breaks, the settings app's diagnostics button gives me everything I need in an issue.

Thank you for reading :)
- Dia

Responses (what's this?)


Send a response

Leave a comment

Commenting is disabled on beta; Discord sign-in returns to the live site. Comment on the live post.

Leave a mention

Sending is disabled on beta; the form targets the live post, so a mention sent from here would land on diaduck.xyz. Send it from the live post.

or reply on Bluesky