Problem and Motivation
Hypixel SkyBlock has about 40 rare dyes. Some drop from slayer bosses, some from fishing, some from dungeon runs, some from killing 3 million skeletons. Each one has a different acquisition method, and the game gives you almost no visibility into how close you are to any of them. The RNG meter dyes have a progress bar in-game, but it only shows for one dye at a time and only while you're doing the relevant content.
I wanted a single dashboard that shows progress for every dye at once. The Hypixel API exposes enough raw stats (slayer XP, dungeon completions, mob kills, skill XP) to estimate progress for most dyes without needing exact meter values. So the baseline is: punch in a username, pull stats, run the math, show percentages.
The companion mod fills in the gaps. It reads exact RNG meter values from chat messages, inventory screens, and the tab list, then syncs them to the backend. Without the mod you get estimates. With it you get precise numbers.
Architecture Overview
It's a pnpm monorepo with four packages. The API runs on Cloudflare Workers with Hono, which ended up being a good fit because there's no state to manage between requests and D1 gives me SQLite without running a database server. The web frontend is a React 19 SPA with TanStack Query handling API caching and refetching. A shared package defines every dye's properties, drop rates, and calculator configs so the API and frontend stay in sync.
The Kotlin Fabric mod is its own thing, lives in a separate directory. It hooks into chat messages to capture RNG meter XP updates, reads inventory screens for selected dye items, and parses the tab list for Mineshaft pity counters. That data gets batched and POSTed to the API with a debounced sync manager. Auth works through Mojang's session server so I never touch passwords: the website generates a link code, the mod sends it to the API, and the server verifies the player's identity by checking a session challenge against Mojang's hasJoined endpoint.
There's also a small Mojang proxy (Hono on Node) because Mojang blocks requests from Cloudflare's IP ranges. I spent more time on that proxy than I want to admit.
Key Technical Decisions and Tradeoffs
The biggest decision was making dye calculations config-driven instead of writing separate functions for each dye. Every dye is a DyeDefinition object with its category, drop rate, stat key, and progress cost. The DyeProgressService dispatches to the right calculator based on category, and the calculator pulls the relevant stat using a dot-path accessor like 'slayer.zombie' or 'bestiary.skeleton_total'. Adding a new slayer dye is just adding a config entry. No calculator changes.
This held up well until I hit the tiered dyes. Aquamarine drops from sea creatures, but common sea creatures have a 1/5M rate while rare ones have 1/50K. A single flat rate doesn't work. So I built tiered probability configs:
export const AQUAMARINE_CONFIG: TieredRateConfig = {
dyeId: 'aquamarine',
magicFindAffected: true,
tiers: [
{ name: 'Low', rate: 1 / 5_000_000, families: ['squid', 'sea_walker'] },
{ name: 'Mid', rate: 1 / 2_500_000, families: ['guardian', 'sea_lurker'] },
{ name: 'High', rate: 1 / 50_000, families: ['sea_emperor', 'carrot_king'] },
],
};
The math is P = 1 - Product((1 - rate_tier)^kills_tier), combining independent probability across tiers. Twelve dyes use this pattern now. The config approach paid for itself here, because adding a new tiered dye is just another object with a tiers array.
I used Mojang session verification for auth instead of OAuth or API keys. The flow: generate a random server ID, have the mod call Mojang's joinServer with it, then the backend calls hasJoined to confirm. Same flow Minecraft servers use to verify connecting players. No passwords, no redirect flows, no third-party auth providers. Only works from inside the game client, which is fine since the mod is the only thing that needs to authenticate.
For UUID lookups I use playerdb.co instead of Mojang's API directly. Mojang rate-limits aggressively and blocks cloud provider IPs. playerdb.co is a community proxy that handles caching and retries. It's a single point of failure I'd rather not depend on, but the alternative was running my own caching layer for Mojang's session API, and that felt like more infrastructure than the problem needed.
Screenshots and Video
No screenshots yet. Planning to capture the dye progress dashboard with category filtering and the mod's in-game sync status display.
Tech Stack with Rationale
- TypeScript across the whole stack. The shared package is the reason this works: dye definitions, rate configs, and API response types are defined once and imported by both sides. I tried Go for the API initially and gave up on code sharing within a day.
- Cloudflare Workers + D1 because I didn't want to manage a server for a niche gaming tool. D1 is SQLite, which is all I need for user records and RNG sync data. KV namespaces handle caching: 1 hour for UUID lookups (those don't change), 5 minutes for profile data (that does).
- Hono for the API framework. Built for edge runtimes, has the middleware pattern I wanted (CORS, error handling, auth guards), and the TypeScript types are solid. Picked it over itty-router because Hono has actual middleware composition.
- React 19 + TanStack Query + Zustand for the frontend. TanStack Query does the heavy lifting for API state with stale-while-revalidate and retry logic. Zustand holds UI state like selected categories and stat inputs. Didn't need Redux for this.
- Drizzle ORM for type-safe queries without the weight of Prisma. Maps cleanly to D1 and the migration tooling works.
- Kotlin + Fabric for the game mod. Minecraft modding has first-class JVM support, and Fabric is lighter than Forge with faster updates after game patches.
- Zod for runtime validation of Hypixel API responses. The API returns deeply nested JSON with no schema guarantees. Parsing it without validation is asking for runtime crashes on someone's weird profile format.
- Vitest for testing. ESM-native, fast, compatible with Workers' module format. Unit tests cover the probability math, integration tests cover full endpoint flows with mocked Hypixel responses.
Challenges and Learnings
The bestiary aggregation was the worst part. Hypixel's API returns raw kill counts keyed by internal mob ID (kills_skeleton_1, kills_gravel_skeleton_2, etc.) with no concept of "families." But dyes like Bone drop from any skeleton variant. So I had to build a mapping from raw mob IDs to family groups, sum kills within each family, and expose that as bestiary.skeleton_total. The mapping has over 50 entries and I found most of them by cross-referencing the SkyBlock wiki, because Hypixel doesn't publish their internal mob IDs anywhere.
Fortune modifiers made the probability math more annoying than expected. Some dyes are affected by Magic Find, some by Farming Fortune, some by Mining Fortune, and some by nothing at all. The Dung dye drops from pests and scales with Farming Fortune, but the formula is effective_rate = base_rate * (1 + fortune / divisor) where the divisor varies by dye type. Getting the divisors right required testing against known in-game values from players who shared their data.
I originally calculated Mango dye progress from foraging XP, estimating trees chopped. A player pointed out that the API actually exposes log collection counts directly. Switched to that, but then realized I had to account for Foraging Fortune: if your fortune is 300, each chop gives 4 logs, so actual_chops = log_count / (1 + fortune / 100). Missed that the first time and was showing progress 4x too high for endgame players. Embarrassing.
The Mojang session proxy was supposed to take twenty minutes. Cloudflare Workers can't hit Mojang's session server because Mojang blocks cloud IPs. So I spun up a small Node proxy. Then I had to handle the proxy being down, add timeouts so verification doesn't hang forever, and deal with Mojang's own rate limits on hasJoined requests. Three hours later it worked.
The dye catalog itself is a moving target. Hypixel adds new dyes, changes drop rates, and renames acquisition methods with no notice. The Archfiend dye showed up with a completely different mechanic (dice rolls instead of mob kills). Every time the game updates I diff the wiki to see if anything changed.