- Why This Exists
- The Problem: Cactbot on Steam Deck
- Architecture Overview
- Why the Code Is the Way It Is
- The Actual Build Order (Chronological)
- Step-by-Step Build Process
- Component Deep Dive
- The Relay Script – raidboss-user.js
- CI/CD and Distribution
- Lessons Learned
- Final Thoughts
If you’ve spent any time raiding in Final Fantasy XIV, you’ve probably come across Cactbot. It’s the overlay that yells “Stack!” or “Spread!” during fights so you don’t have to memorise every mechanic. For those who use it, it can be useful, it’s popular, and on Windows it mostly just works.
On Steam Deck it’s a different story.
This article walks through how I built a plugin that fixes Cactbot on Steam Deck, and more importantly, why I made the decisions I did along the way. If you’re interested in Dalamud plugin development, embedded browsers, or just like seeing how things are put together, there might be something useful here.
Disclaimer: I don’t personally use Cactbot and I am unable to confirm it works completely as intended. I have tested it in a few instances and it is showing the correct data and I don’t think I missed anything but I may have. You also can’t change the Cactbot config settings on the Steam Deck as you can’t load the settings without adding a browser as a game and doing it that way but I might add the option in future. There are no promises I will update the plugin but assuming Dalamud doesn’t change anything major I will likely keep it updated.
Why This Exists
Two reasons, really.
First: I wanted a portfolio project that showed I could work with C# and a bunch of different APIs – WebSockets, HTTP, embedded browsers, ImGui rendering – all in one place. Something that wasn’t just another CRUD app.
Second: I don’t really play FFXIV much anymore, but I still keep up with the community. I posted on my personal Discord asking what I should build – something that would actually be useful, not just another generic programming exercise. A few people suggested a Cactbot plugin for Steam Deck, because the existing solution (Browsing Way) barely worked on Linux. It tanks your frame rate, it’s a pain to configure, and most people had just given up on it.
So I built it.
The Problem: Cactbot on Steam Deck
Here’s how Cactbot normally works. You run IINACT (an ACT clone for FFXIV) which includes OverlayPlugin. OverlayPlugin has a WebSocket server on port 10501. You open a browser window – or an embedded browser via Browsing Way – and point it at a URL like:
https://proxy.iinact.com/overlay/cactbot/ui/raidboss/raidboss.html?OVERLAY_WS=ws://127.0.0.1:10501/ws
That page loads Cactbot’s JavaScript, which connects to the WebSocket, processes combat events, and displays alerts. It works. But:
- On Steam Deck, Browsing Way causes massive frame rate drops because it’s rendering a full browser on top of a game already running through a compatibility layer.
- Setup is manual. You need to configure the overlay URL correctly, which means knowing what you’re doing.
- Two separate things to run. IINACT for the data, a browser for the display. More points of failure.
The goal was simple: install one plugin, it works. No separate browser window, no manual URL configuration, no frame rate hit.
Architecture Overview
Before I dive into the build order, here’s the big picture of how the plugin works. The data flows through six stages from game event to on-screen alert:
- RelayHttpService starts a local HTTP server that acts as a reverse proxy for the remote Cactbot overlay page, injecting our relay script into it.
- BrowserService fires up a headless Chromium browser (downloaded and managed by PuppeteerSharp) and points it at that local server.
- The Cactbot page runs inside Chromium. When an alert fires, our injected
raidboss-user.jscatches it and broadcasts it back through OverlayPlugin. - WebSocketService – connected to IINACT’s WebSocket – receives that broadcast and queues it.
- OverlayWindow (an ImGui overlay rendered inside the game) reads the queue each frame and draws the text on screen.
- ConfigWindow lets you tweak fonts, colours, position, and all that.
The key insight here is that the headless browser never shows up on screen. It runs invisibly in the background, does its job of running the Cactbot JavaScript, and the actual alert text is pulled out and rendered through Dalamud’s own UI system. That’s how we avoid the frame rate problem.
Why the Code Is the Way It Is
Before I get into the build order, let me explain the thinking behind the major design decisions. This might help if you’re wondering why I didn’t just use X or Y off-the-shelf solution.
Why Three Separate Services?
The plugin has three service classes: WebSocketService, RelayHttpService, and BrowserService. I could have mashed them into one class, but each one has a completely different:
- Failure mode. The WebSocket disconnects and reconnects. The HTTP server fails to bind. The browser crashes or takes 30 seconds to download.
- Lifetime. The browser download is async and can take minutes. The HTTP server starts synchronously in milliseconds. The WebSocket loops forever.
- Threading requirement. The WebSocket runs entirely on a background thread. The HTTP server uses async I/O. The browser spawns its own processes.
Keeping them separate meant I could test each one independently and handle failures without the whole thing falling over. It’s not elegant, it’s practical.
Why Raw TCP Instead of ASP.NET?
I needed a local HTTP server that serves exactly three things: the proxied Cactbot page, the relay JavaScript file, and 404s for everything else. ASP.NET (or even Kestrel) would have added megabytes of dependencies to a plugin that’s supposed to be a lightweight ZIP file. A TcpListener with manual HTTP response writing is about 150 lines and has zero dependencies beyond the .NET runtime that’s already there.
The downside? I’m parsing raw HTTP requests with string splitting. That’s fragile. If the Cactbot page ever sends a weird request, it might break. But for a portfolio project that serves one purpose, it’s fine.
Why Headless Chromium Instead of CEF?
CEF (Chromium Embedded Framework) is the usual approach for embedding a browser in a desktop app. But CEF requires native compilation, platform-specific binaries, and a lot of setup pain. PuppeteerSharp, on the other hand, is a pure managed NuGet package that handles downloading and launching Chromium itself. It’s the same library used for browser automation in testing. It works on Windows and Linux (Steam Deck). The trade-off is a ~150 MB one-time download, but that’s a one-time cost.
Why Both TransformText AND MutationObserver?
This one bit me during testing. Cactbot has two code paths for displaying alerts:
- It calls
Options.TransformText()before inserting text into the DOM. - It creates DOM elements with classes like
alarm-text,alert-text, orinfo-text.
If I only hooked TransformText, I’d catch the text but not the severity type (it’s not passed to that function). If I only used a MutationObserver, I’d miss alerts that don’t create DOM elements. Using both with a dedup map means I catch everything without double-firing.
This is the kind of thing you only discover by testing against the actual software. Reading the Cactbot source told me about TransformText. Watching the DOM in dev tools told me about the MutationObserver approach. I needed both.
Why Thread-Safe Alert Queue?
Here’s a common gotcha in game plugin development: the WebSocket receives data on a background thread, but ImGui rendering happens on the game’s main thread. If you modify a list from two threads without locking, you get corruption, crashes, or both. The lock(alertLock) guard is simple and effective. The ConcurrentQueue<string> for chat messages handles the other direction.
Why Manual Position Persistence?
ImGui has a built-in window position saving system, but it doesn’t play well with click-through transparent windows that have NoInputs set. I store the overlay position in the config file and apply it manually each frame. When the user drags the overlay (in move mode), I detect the position change and save it. It’s more code, but it’s reliable.
The Actual Build Order (Chronological)
Here’s the thing about building software: you rarely build it in the order the final architecture suggests. I didn’t start with a grand plan and execute it. I started with the simplest thing that could work and iterated. Here’s how it actually happened.
Phase 1: Project Skeleton
I started from Dalamud’s official SamplePlugin template. This gave me the boilerplate: the IDalamudPlugin interface, service injection attributes, a WindowSystem for managing ImGui windows, and the .csproj setup with Dalamud.NET.Sdk.
Why start from a template? Because Dalamud plugins have specific requirements around how they’re loaded, how they interact with the game, and how they’re packaged. The template handles all of that. Writing it from scratch would mean reading a lot of documentation to rediscover things the template already does right.
I created three window classes first: MainWindow (a basic info screen), ConfigWindow (settings), and OverlayWindow (the actual alert display). At this point they were empty shells. I just wanted to prove I could get ImGui windows rendering inside FFXIV.
Phase 2: WebSocket Connection
The first real code I wrote was WebSocketService. I connected to IINACT’s ws://127.0.0.1:10501/ws, sent a subscription message, and started printing incoming JSON to Dalamud’s log. This was the make-or-break moment: if I couldn’t get data out of OverlayPlugin, nothing else mattered.
Why the WebSocket first? Because it’s the data source. Without incoming alerts, there’s nothing to display. Everything else – the browser, the relay, the HTTP server – exists to make this data usable. Starting with the data source meant I could validate the whole concept before investing time in the infrastructure around it.
This is where I ran into the first major gotcha. I subscribed to events called onBroadcastMessage and onInCombat based on outdated documentation. Those events don’t fire from IINACT’s built-in Cactbot source because it doesn’t run the JavaScript layer. The actual event I needed was just BroadcastMessage (no “on” prefix), which fires when a browser-based overlay sends a broadcast. Figuring that out took a lot of staring at raw JSON dumps.
Phase 3: The Relay HTTP Server
Once I had the WebSocket working, I realised I had a chicken-and-egg problem. To get Cactbot alerts, I needed the Cactbot JavaScript running. To run the Cactbot JavaScript, I needed a browser to load the overlay page. And to load the overlay page, I needed the overlay page served somewhere.
I could have asked users to configure the overlay URL manually and run it in Browsing Way, but that defeated the purpose. I needed to serve the page myself.
So I wrote RelayHttpService – a tiny HTTP server using TcpListener. It fetches the Cactbot HTML from the remote proxy, injects a <base> tag (so CSS and fonts load correctly) and my relay script, then serves the result to whoever connects.
Why inject a <base> tag? The Cactbot page loads assets like ../resources/fonts.css relative to its own URL. Without the <base> tag pointing back to the real server, the headless browser would try to load them from localhost:9876 and fail with 404 errors. This is one of those things that looks obvious in hindsight but took a while to debug.
Phase 4: Embedded Chromium
Now I had an HTTP server serving a modified Cactbot page, but nothing was loading it. Enter BrowserService and PuppeteerSharp.
var fetcher = new BrowserFetcher(new BrowserFetcherOptions { Path = chromiumPath });
var revisionInfo = await fetcher.DownloadAsync();
var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
ExecutablePath = executablePath,
Args = new[] { "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage" },
});
Why PuppeteerSharp? It’s a well-known library for browser automation that handles Chromium downloads across platforms. It works on Windows, Linux, and macOS. The --no-sandbox flag is essential for running under Wine/Proton on Steam Deck. --disable-gpu prevents the browser from wasting resources rendering things nobody will see. --disable-dev-shm-usage avoids a common crash on Linux containers.
The first launch downloads ~150 MB of Chromium to {pluginDir}/chromium/. I added a custom download handler with progress reporting so the config window could show a progress bar. Subsequent launches use the cached copy.
Phase 5: The Relay Script
With the browser running and the page loaded, I needed a way to get alerts out of the browser and back to the plugin. This is where raidboss-user.js comes in.
I inject this JavaScript file into the Cactbot page before </body>. It hooks into Cactbot’s alert pipeline using two methods:
- Options.TransformText – a callback that Cactbot calls for every alert text before rendering it. This catches the text but doesn’t tell us the severity.
- MutationObserver – watches the DOM for new elements with classes
alarm-text,alert-text, orinfo-text. This gives us the severity but fires after the DOM change.
Each method calls callOverlayHandler({call: 'broadcast', ...}), which sends the alert text back through OverlayPlugin’s WebSocket to my WebSocketService.
Why both methods? Because I kept missing alerts with just one method. The TransformText hook fires early but lacks context. The MutationObserver fires late but has full context. A dedup map with a 3-second window stops them from doubling up.
Phase 6: Overlay Rendering
OverlayWindow is the part the player actually sees. It’s an ImGui window with:
NoTitleBar– no chrome, just textNoScrollbar– we handle scrolling ourselvesNoSavedSettings– we persist position manuallyNoInputs– click-through so it doesn’t block game interactionsNoFocusOnAppearing– don’t steal keyboard focus
The rendering pipeline is straightforward:
- Pull alerts from the thread-safe queue
- Measure text, word-wrap to fit the box
- Draw each line centred, with colour per severity type
- Apply fade-out alpha for expired alerts
- Persist any position/size changes to config
Why manual word wrapping? I could have used ImGui’s TextWrapped flag, but that left-aligns text. I wanted each line centred independently. So I split by spaces, call CalcTextSize to measure, and wrap when the width exceeds the box. It’s a few lines of code and gives precise control.
Why zero allocations per frame? Dalamud plugins run inside the game’s frame loop. Every heap allocation triggers garbage collection eventually, and GC pauses cause stutter. I reuse a List<CactbotAlert> that’s cleared each frame instead of allocating new lists.
Phase 7: Configuration & Polish
With the core working, I added configuration:
- Overlay position and size
- Font family (the game’s native fonts: Axis, Jupiter, Trump Gothic)
- Font scale
- Custom text colour and outline
- Max visible alerts
- Toggle for chat output
Each change saves immediately via Configuration.Save(), which writes to Dalamud’s config storage.
Then came the CI/CD pipeline (GitHub Actions), the release page (docs/index.html), version bumps, and bug fixes. The rest of the Git history is mostly that – iteration and polish.
Step-by-Step Build Process
Starting Point – Dalamud SamplePlugin
Every Dalamud plugin starts the same way: from the SamplePlugin template. It gives you:
- The
IDalamudPlugininterface – how Dalamud discovers and loads your plugin [PluginService]attributes – dependency injection for game servicesWindowSystem– manages ImGui window lifecycleDalamud.NET.Sdk– the MSBuild SDK that handles all the Dalamud-specific packaging
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<!-- Dalamud 10.0 / API Level 15 -->
</Project>
The template also sets up the right target framework (net10.0-windows) and includes the DalamudPackager that builds the .zip file Dalamud expects.
Renaming Everything
The template used SamplePlugin everywhere. I renamed:
SamplePlugin/toSteamyCactbot/- Root namespace:
CactbotUI - Assembly name:
CactbotUI - Internal name:
CactbotUI
The build output is CactbotUI.zip. The assembly name has to match what’s in the JSON manifest, or Dalamud won’t load it.
The Three Services
| Service | Job | When It Starts |
|---|---|---|
WebSocketService | Maintains WebSocket to IINACT, parses messages, manages alert queue | Constructor |
RelayHttpService | Local HTTP server, reverse-proxies Cactbot overlay | Constructor |
BrowserService | Downloads and runs headless Chromium | Constructor (async) |
All three start automatically when the plugin loads. All three are disposed cleanly when the plugin unloads.
Why start them in the constructor? Dalamud plugins don’t have a separate “start” method. The constructor is it. But starting async work in a constructor is generally frowned upon. The compromise is that the constructor fires off the async work with Task.Run() and the service manages its own lifecycle via CancellationTokenSource.
The WebSocket – Where the Data Comes From
IINACT’s OverlayPlugin has a WebSocket server on port 10501. The protocol is simple JSON:
- Connect to
ws://127.0.0.1:10501/ws - Send:
{"call": "subscribe", "events": ["BroadcastMessage", "LogLine", "ChangeZone", ...]} - Receive JSON messages in a loop
Here’s where people usually get confused: the event names in OverlayPlugin are inconsistent. Some have an on prefix (onBroadcastMessage), some don’t (BroadcastMessage). Some fire from the built-in Cactbot source, others only fire from browser-based overlays. I had to experiment to find the right combination.
The service handles several message types:
| Event | What It Does |
|---|---|
BroadcastMessage | Cactbot alert from the relay script to EnqueueAlert() |
ChangeZone | Updates current zone name, shows zone change alert |
LogLine | Parses ACT log lines for countdown timers (types 268/269) |
ImportedLogLines | Cactbot test mode – handles synthetic log lines |
The auto-reconnect loop is essential. IINACT might not be running when the plugin starts, or might crash and restart. The service loops forever with a 5-second delay between reconnection attempts. This makes the plugin resilient to startup order.
Thread safety note: The WebSocket runs on a background thread. GetActiveAlerts() is called from the ImGui draw thread on the main game thread. Both the alert list and the debug message list are protected by locks.
The Relay HTTP Server – Serving the Overlay
I needed a local server to serve the proxied Cactbot page. ASP.NET was out – too heavy. So I wrote a raw TcpListener:
- Binds to
127.0.0.1:9876, tries up to 20 ports if that’s taken - No admin rights needed (loopback ports are unrestricted)
- Handles one request at a time with async I/O
- Serves exactly three things:
GET /to the proxied Cactbot page with injected scriptsGET /raidboss-user.jsto the relay script- Everything else to 404
The proxy fetch:
var html = await HttpClient.GetStringAsync(remoteUrl, ct);
html = InjectAfterMarker(html, "<head>", baseTag);
html = InjectBeforeMarker(html, "</body>", scriptTag);
Why not cache the proxied page? Because the Cactbot overlay URL includes query parameters for configuration (?alerts=1&timeline=0&...). Different configurations might have different settings. Fetching fresh each request is simpler and avoids stale state.
Embedded Chromium – The Secret Sauce
This is the core innovation. Instead of relying on Browsing Way (which kills frame rate on Steam Deck), the plugin downloads and runs its own headless Chromium.
var fetcher = new BrowserFetcher(new BrowserFetcherOptions { Path = chromiumPath });
var revisionInfo = await fetcher.DownloadAsync();
The first download is ~150 MB. After that, it’s cached. The config window shows a progress bar during the initial download, which is important because it takes a while and users will think the plugin is broken otherwise.
Why Chromium and not WebView2? WebView2 requires the WebView2 runtime to be installed, which isn’t guaranteed on Steam Deck. Chromium via PuppeteerSharp is self-contained.
Steam Deck compatibility: The --no-sandbox flag is required because Wine/Proton can’t use Linux namespaces for sandboxing. --disable-dev-shm-usage prevents crashes in memory-constrained environments.
The ImGui Overlay – What the Player Sees
OverlayWindow is transparent, always-on, and click-through by default. It only accepts input in move mode, which is toggled with /cactbot.
The rendering:
- Fetch alerts from
WebSocketService.GetActiveAlerts()– returns a thread-safe snapshot - Word-wrap each alert’s text to fit the overlay box
- Draw each line centred, with per-type colouring (red for alarm, orange for alert, white for info)
- Apply fade-out alpha during the last second of each alert’s lifetime
- Persist position and size changes when the user drags or resizes
Why centre text? Raid alerts like “Stack!” or “Spread” are short. Centring them looks better and makes them easier to read at a glance. For longer text, each line is centred independently, which creates a clean block layout.
Configuration
Settings are stored using Dalamud’s IPluginConfiguration interface, which serialises to a JSON file in the plugin config directory. Every change calls Save() immediately.
| Setting | Default | Why It’s There |
|---|---|---|
OverlayX/Y | 100, 100 | Where the overlay sits on screen |
OverlayWidth/Height | 500, 150 | Size of the alert box |
MaxVisibleAlerts | 5 | Don’t crowd the screen |
AlertFontScale | 1.2 | Default game font is small |
AlertFontPreset | Axis | FFXIV’s standard UI font |
AlertTextColor | White | Custom colour override |
AlertTextOutline | false | Text shadow for readability |
OutputToChatAnnouncement | false | Also print to chat log |
Component Deep Dive
Plugin.cs – Entry Point
This is where Dalamud starts. The [PluginService] attributes tell Dalamud to inject game services before the constructor runs:
[PluginService] internal static IDalamudPluginInterface PluginInterface { get; private set; }
[PluginService] internal static ICommandManager CommandManager { get; private set; }
[PluginService] internal static IClientState ClientState { get; private set; }
[PluginService] internal static IPluginLog Log { get; private set; }
[PluginService] internal static IChatGui ChatGui { get; private set; }
[PluginService] internal static IFramework Framework { get; private set; }
The constructor creates all services and windows, registers the /cactbot slash command, and hooks into the game’s update loop.
Why IFramework.Update for chat? ChatGui.Print() must be called from the game’s main thread. The WebSocket runs on a background thread. So I queue chat messages in a ConcurrentQueue and drain them in the OnFrameworkUpdate handler, which runs on the main thread.
Dispose does everything in reverse: unsubscribe events, dispose windows, stop services, remove command. If you forget to clean up, the plugin leaks on reload and causes problems.
WebSocketService – Message Handling
The message handler dispatches by event type. The main pathways:
BroadcastMessage → HandleBroadcast() → EnqueueAlert()
ChangeZone → Update zone name → EnqueueAlert(zone changed)
LogLine → HandleActLogLine() → process type 268/269
HandleBroadcast() is where the relay script’s messages arrive. The payload shape is:
{ "type": "alarm", "text": "Stack!", "duration": 5.0 }
But some Cactbot overlays send different field names (alarmText, alertText, infoText, tts). I check for all of them.
HandleActLogLine() parses raw ACT log lines. Type 268 is a countdown timer, type 269 is a countdown cancel. The countdown handler creates an alert with a CountdownEndTime property, which the overlay window uses to show a live countdown (“Engage in 5.2s!”).
RelayHttpService – The Proxy
The injection logic is straightforward string manipulation:
// Inject <base> so relative assets resolve correctly
html = InjectAfterMarker(html, "<head>", baseTag);
// Inject relay script before </body>
html = InjectBeforeMarker(html, "</body>", scriptTag);
The <base> tag is critical. Without it, the Cactbot page loads its CSS and fonts from localhost:9876 instead of the real server. Everything looks broken.
The relay script is read from raidboss-user.js, which is bundled with the plugin and copied to the output directory by the .csproj:
<None Include="..\Data\cactbot-user\raidboss-user.js">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<TargetPath>raidboss-user.js</TargetPath>
</None>
BrowserService – State Machine
The browser goes through clear states: Idle, Downloading, Launching, Running, Error. The config window displays the current state so users can see what’s happening.
The Restart() method cancels the current operation and starts fresh. Useful if the browser crashes (which happens occasionally with headless Chromium) or if the user wants to reload their triggers.
OverlayWindow – Rendering Pipeline
The draw method runs every frame:
- Collect active alerts from the WebSocket service
- If the config window is open, show preview alerts instead
- Measure total text height for vertical centering
- For each alert:
- Build the display string (handles countdowns and cast bars)
- Determine colour (per-type or custom override)
- Word-wrap to fit box width
- Draw each line centred, with optional outline
- Apply fade alpha
- Save position and size if changed
The word-wrapping is manual but effective:
foreach (var word in words)
{
var testLine = currentLine.Length == 0 ? word : currentLine + " " + word;
var testSize = ImGui.CalcTextSize(testLine);
if (testSize.X > wrapWidth && currentLine.Length > 0)
{
lines.Add(currentLine);
currentLine = word;
}
else currentLine = testLine;
}
Why manual? ImGui’s built-in wrapping left-aligns. I need centred text. Manual wrapping gives me each line individually so I can centre them.
The Relay Script – raidboss-user.js
This is the glue. Let’s trace what happens when a boss uses a mechanic:
- Cactbot’s
popup-text.tsdetects the mechanic and calls_addTextFor()to display an alert. _addTextFor()callsOptions.TransformText()– our hook catches it and broadcasts the text as ‘info’ (we don’t know the severity yet)._addTextFor()creates a DOM element with classalarm-text,alert-text, orinfo-text.- Our
MutationObserversees the new element and broadcasts with the correct severity. - OverlayPlugin receives
call: 'broadcast'and fires aBroadcastMessageevent on the WebSocket. WebSocketService.HandleBroadcast()parses it and callsEnqueueAlert().OverlayWindow.Draw()picks it up next frame and renders it on screen.
The dedup map prevents steps 2 and 4 from sending two messages for the same alert. It stores the text and timestamp, and ignores repeats within 3 seconds.
const seen = new Map();
function sendDeduped(text, type) {
const key = String(text ?? '').trim();
if (!key) return;
const now = Date.now();
const last = seen.get(key);
if (last !== undefined && now - last < 3000) return;
seen.set(key, now);
sendBroadcast(text, type);
}
CI/CD and Distribution
GitHub Actions
PR Build runs on pull requests to verify the code compiles. It downloads Dalamud’s hooks and runs dotnet build. Simple, effective.
Release & Pages triggers when I publish a GitHub release. It:
- Extracts version info from the tag
- Generates
version.json(for the release page) andplugin.json(for Dalamud’s custom repo system) - Deploys to GitHub Pages
The plugin.json is what makes this a “custom repo” plugin. Users add the URL to Dalamud’s settings and the plugin appears in their installer.
Lessons Learned
What Worked
PuppeteerSharp was the right call. It handles Chromium downloads across platforms, works under Wine/Proton, and the API is straightforward. The initial 150 MB download is a pain, but it’s a one-time cost.
Raw TCP HTTP server kept things lean. No ASP.NET, no Kestrel, no dependency hell. About 150 lines of code for exactly what I needed.
Reconnecting WebSocket makes the plugin resilient. Players don’t need to start IINACT first. If it crashes mid-session, alerts resume automatically.
Two-method relay script catches everything. TransformText + MutationObserver with dedup handles both of Cactbot’s code paths without duplicates.
What I’d Do Differently
The HTML injection is fragile. I’m doing string replacement on HTML to inject the <base> tag and relay script. A proper HTML parser would be more robust, but for a portfolio project it works.
The Chromium download UX is rough. 150 MB with no ETA on first launch isn’t great. Bundling a minimal Chromium build in the release ZIP would be better, but it would bloat the artifact significantly.
Font scaling via ImGui is blurry. SetWindowFontScale works but looks soft at non-integer scales. SDF rendering or bitmap fonts would be sharper, but the game’s native fonts are already bitmap-based, so it’s tolerable.
No test mode. Cactbot has an “Import Log Lines” feature for testing triggers, but there’s no way to verify the relay script is working without being in an actual instance. A test button in the config window would be a nice addition.
Common Mistakes to Avoid
If you’re building something similar:
- Subscribe to the right events. OverlayPlugin’s event naming is inconsistent. Test against actual traffic, don’t trust documentation blindly.
- Handle thread safety early. The WebSocket thread and the ImGui thread are different. Lock your collections.
- Test on your target platform. Running under Wine/Proton reveals issues you won’t see on Windows (sandbox flags, filesystem paths, Chromium compatibility).
- The
<base>tag matters. If you’re proxying a web page, make sure relative URLs resolve correctly. - Prepare for first-launch delays. Downloading 150 MB of Chromium takes time. Show progress, or users will think the plugin is broken.
Final Thoughts
Building SteamyCactbot taught me a few things that apply beyond this specific project.
First, start with the data source. Before you build any UI or infrastructure, make sure you can actually get the data you need. I validated the WebSocket connection before writing the browser service or the overlay renderer. That saved me from investing time in infrastructure that would have been pointless if the data path didn’t work.
Second, test against the real system. The Cactbot documentation said onBroadcastMessage was the event to use. The real OverlayPlugin sent BroadcastMessage without the prefix. Documentation lies. Code doesn’t.
Third, understand your deployment target. The Steam Deck runs Linux under Wine/Proton. That means no sandbox support, different filesystem paths, and different performance characteristics. Testing on Windows alone would have missed most of the problems this plugin was designed to solve.
And finally, portfolio projects should solve real problems. Anyone can build another to-do app. Solving something that actually annoys people – like Cactbot on Steam Deck – is more interesting to build and more impressive to show.
The code is on GitHub if you want to poke around. It’s not perfect, but it works, and that’s what matters.

Join the Discussion
If you could create a Dalamud plugin, what problem would you want to solve?