blog

MCA Benches - GW2 Benchmark Tracker

Guild Wars 2 endgame raiding is, more or less, a damage optimization problem. Every build has a known theoretical ceiling: the DPS you can squeeze out of a perfect rotation against a stationary training golem. The community site Snow Crows publishes those numbers as official benchmarks, and a big part of getting into a serious squad is proving you can get close to them.

My guild [MCA] (join.mca.gg) is one of the top Guild Wars 2 raiding guilds, with tens of active world records, and wanted a way to track that, plus a clear view of who can play what so raid nights are easier to plan. The workflow before was the one every guild has: people paste dps.report links into a Discord channel, an officer squints at them, and the results end up in a spreadsheet nobody fully trusts. That doesn’t scale, and checking “did you actually run the right food?” by hand is nobody’s idea of fun. So I built MCA Benches.

That was three months ago and it’s grown well past validating logs since, so this is a second pass over it, including the parts that fought back.

What it does

For every build the app keeps an official bench: a reference dps.report log plus its target number, imported straight from the build’s Snow Crows page. Players then submit their own attempts as dps.report links, and the app:

  • auto-validates the attempt against the official bench (right spec, weapons, food, buffs, golem),
  • diffs the rotation against the reference cast by cast,
  • and ranks everyone on a leaderboard by how close their valid attempts get.

Build detail - the official bench plus every player's personal attempt, with validation state

Around that there’s now an achievement system, per-patch recaps, saved squad compositions, raid night planning that posts itself to Discord, and cross-guild tournaments with a live draft and a bracket. One deployment serves several guilds at once, which is where most of the design work went.

Where a log comes from

This is worth a detour, because the chain behind that number is a community built Rube Goldberg machine and none of it is official. Guild Wars 2 exposes no API for combat data at all, and I’ve written before about what it takes to pull it out of the running client yourself. What the raiding scene uses instead is arcdps, a third-party addon that hooks the game and records every combat event it sees: skill casts, damage ticks, buffs applied and removed, all timestamped to the millisecond. When a fight ends it dumps everything into a compressed .evtc file on your disk.

That file is unreadable on its own, so you upload it to dps.report, which runs it through Elite Insights, the open source parser the community maintains. Out comes a structured report: per player DPS, the full rotation, buff uptimes, damage broken down by skill, what everyone was wearing and eating. So the whole competitive side of the game rests on a hooking addon, a volunteer parser and a community upload service, none of which ArenaNet built or is obliged to keep working. It holds up remarkably well.

dps.report hands you back a permalink. People were pasting those into Discord long before I wrote anything, and I think building on a habit that already existed is most of the reason this ever got used.

Ingesting logs

MCA Benches never touches the game or the raw file. Everything starts from a dps.report link somebody pasted. dps.report exposes a getJson endpoint that returns the parsed report, so the whole pipeline is short: pull the permalink out of the URL, fetch the JSON, then read structured data out of one big blob. Logs are immutable once uploaded, so the fetch is force-cache and we never pay for the same log twice.

Out of that JSON I pull the DPS, the recorder’s account name, spec, weapon sets, consumables, buff uptimes, the full skill rotation, and the per-skill damage distribution. The rest of the app is really just a bunch of opinionated views over that structure.

Two things that used to be typed in by hand are now imported. Builds come from Snow Crows: paste the build’s page and the app scrapes its traits, gear and published DPS, then keys the build on that URL, so a re-bench next patch lands on the existing build instead of creating a duplicate. Patches sync from GW2 Wingman in one click. Wingman doubles as a second accepted source for raid logs, since a log already linked in Discord shouldn’t need re-uploading somewhere else.

Auto-validation

The tricky thing about “prove you hit the bench” is that a big number on its own means nothing. You can inflate DPS with the wrong food, an extra boon from a friendly source, or a weapon set the build never intended. So the official bench log is the source of truth, and a submission is only auto-valid if it matches on the things that actually change the result.

The check itself is a pure function, which makes it easy to unit test and easy to reason about. It walks a list of rules and collects reasons to reject:

  • Account - the log must be recorded by the account tied to that player.
  • Spec - the log’s elite spec must match the build.
  • Weapons - compared without caring about order. I normalize each weapon set (sort within a set, then sort the sets) so swapping which set is “first” doesn’t register as a difference.
  • Consumables - food and utility are compared by their stats, not by the exact item. A different food with the same stats isn’t cheating, only a genuinely stronger one is.
  • Boons / conditions - the subtle one. Some builds scale damage per boon or per unique condition, so the count only gets flagged when the build actually has a scaling modifier and your log carries more of them than the official one. Carrying fewer is your own loss, so it passes.
  • Damage-modifier uptimes - things like “hit from behind” or “target above 90% health”. If you kept a modifier up far more than the reference log did, beyond a tolerance, then something about your encounter was easier than it should have been.
  • The golem - no bigger than the one the official bench used, the same health pool, and dead by the end of the log.

If nothing trips, the bench is auto-valid. Reviewers can still override either way, with a reason, and that manual verdict wins.

Since every one of those rules reads its answer off the official log, the build page can just show you the answer up front instead of only telling you afterwards why your log got rejected.

The "before you bench" card on a build page, listing the official log's weapons, food, utility, boon count and golem

Three of those rules took me a long time to get right.

Boon scaling has to come from the build’s traits, not from the log. My first version asked the log which damage modifiers existed. Elite Insights helpfully lists every modifier your profession could trait, whether you took it or not, so almost every submission got flagged and almost every flag then got overridden by hand. Not great. The fix was to scrape traits at Snow Crows import time and ask the build instead. Manual overrides more or less stopped after that.

The golem is read off the official bench log rather than hardcoded. It would be tidy to assume the standard training golem, but a bench can be a guild’s own recording rather than an imported Snow Crows one, and hammer Renegade’s official bench is on the Large Kitty Golem. Holding every build to one standard would reject exactly the runs that copied the official setup most faithfully.

Off-weapon damage is the one that feels like an arms race. Park a staff on your second weapon set, cast Meteor Shower out of combat, swap back to your real weapons, then pull. The meteors land inside the log and count towards your number, but a log only starts when the fight does, so the cast itself happens outside it. arcdps never recorded it, so Elite Insights ends up looking at damage from a weapon it has no evidence you ever held, and reports the unused set as “Unknown”. Every check above passes happily. Catching it means comparing which weapons dealt damage against which weapons you cast from, and flagging damage credited to a weapon that never shows up in the rotation. Honest precasting still works, because a Dragon’s Tooth thrown a moment before the pull comes off the weapons you actually go on to fight with.

The 89% ceiling

Then there’s a validation problem that has nothing to do with cheating, which I really didn’t see coming. Thief builds that share damaging venoms apply them to four allies as well as themselves, and Snow Crows publishes a benchmark number that includes that ally damage, measured against a log recorded solo. So a flawless run scores about 89% and there’s nothing the player can do about it.

The only fix is to compute the missing half yourself. The app reads the venom casts out of the log and simulates what those shared procs would have done on four allies: six poison stacks per cast, the game’s standard condition formula, the Poison Master multiplier, 25 stacks of vulnerability on the golem, and the average uptime of the build’s stacking damage buffs, read back out of the log. Casts too close together get deduplicated, and a cast near the end of the fight is prorated instead of counted whole.

Getting the numbers to line up was the hard part. I ported the community calculator first and it undershot every published benchmark, because it hardcodes a 6 second poison duration when the duration actually scales with the build’s expertise. Its own build table even carries a flag for this that the formula never reads. Solving the duration back out of the published numbers per build gets within about 1% on all four Antiquary benchmarks. Most affected benches move by 1-3%, and the submission form shows what the simulation added so you can see where your number came from.

Diffing rotations

This is still my favourite part. Two rotations are just two sequences of skill casts, and I want to line them up so a player can see exactly where they drifted: which casts match, which ones they skipped, and which ones they added that the reference never used.

That’s a diff. Same problem git diff solves, and the same algorithm underneath: the longest common subsequence. I filter out auto-attacks and weapon swaps, which are pure noise here, then build the classic LCS table over skill IDs and backtrack it into match / official-only / personal-only rows:

// off[] and pers[] are the two cast sequences, by skillId
for (let i = 1; i <= n; i++) {
  for (let j = 1; j <= m; j++) {
    dp[i][j] =
      off[i - 1].skillId === pers[j - 1].skillId
        ? dp[i - 1][j - 1] + 1
        : Math.max(dp[i - 1][j], dp[i][j - 1]);
  }
}
// backtrack: equal skill -> "match", otherwise step the side
// with the larger LCS value to emit an "only" row

The backtrack walks from the bottom-right corner: when the two casts agree it emits a match and steps both sequences, otherwise it follows whichever neighbour preserved the longer subsequence and emits the dropped cast as official-only or personal-only. Rendered side by side with timestamps, you get a readable timeline of where a rotation fell apart, plus per-skill damage deltas, buff uptimes, first-cast timing, and where you went idle between casts. The idle gaps explain more lost DPS than a wrong cast order does, which I hadn’t expected.

Here is the same diff, running on a short rotation you can break yourself. Drop a cast, add one the reference never used, or load one of the presets, and watch the alignment rearrange itself:

Try it: break the rotation

Preset
Your casts (click one to drop it)
Append

Auto-attacks and weapon swaps are filtered out before the diff runs, the same as in the app, which is why a rotation this short is readable at all. Real ones are a few hundred casts long.

Rotation comparison - a personal attempt diffed against the official log

Leaderboard, badges and recaps

Ranking is deliberately forgiving. Hitting 100% of a Snow Crows benchmark in a guild setting is unrealistic, so getting close should still be worth something. Each valid attempt is scored as a percentage of the official number and bucketed into points:

Closeness to officialPoints
≥ 100%4
≥ 97.5%3
≥ 95%2
≥ 92.5%1
below that0

Only a player’s best valid attempt per build counts. Points get summed across builds, ties are broken by average percentage, and ranks use standard competition ranking (“1224”, so tied players share a rank and the next distinct row jumps past them). Those four cutoffs are per-guild configuration now, because a casual guild wants a gentler scale than a world record one.

Leaderboard - players ranked by how close their valid benches come to the official numbers

On top of that there are about 35 achievements in tiers, announced in Discord when they unlock. Some are derived live from existing data, but the ones that depend on a moment in time have to be written to a ledger, and a ledger can go stale. So if the bench behind a badge gets edited or invalidated later, a reconciliation pass takes the badge back instead of leaving it standing.

Achievements - the badge catalog, with tier progress and current holders

When a patch closes, every player gets a recap: best benches, rank movement, class spread, awards. It renders to a downloadable 1200x630 PNG, drawn client-side on a 2D canvas. The module is split in half: a pure half that turns data into display strings, unit tested because that’s where something can be silently wrong, and an imperative half that pushes pixels, which I verified by looking at it.

Patch recap - a per-player summary of a patch, downloadable as a PNG

Crewing a squad is an assignment problem

Benchmarks were always a means to an end. What the guild actually wants is to know who can play what, so raid nights can be planned. That’s where comps came from: a reusable squad for one encounter, ten positions, each with a build or a reserved role like “Any Quickness”. On top of those sit plannings, one specific evening with a date, one or more bosses, and a crew for each, pushed to Discord as an embed the guild’s bot keeps up to date as the plan changes.

A night's plan - two bosses, a player-by-boss overview, and the position grid below it

Splitting those two apart was the first real modelling decision. They used to be one thing, which meant an evening’s roster got written into a template that then gets reused every week. Now there’s exactly one place where a guild says who plays a position, and that’s the planning, not the comp.

Then there’s Autofill: given the players who signed up for an evening, crew the squad. The obvious implementation, walking the positions and taking the best available candidate for each, is wrong in a way that’s easy to miss. Take the first candidate and the player who can cover both the quickness position and a DPS position gets spent on whichever one comes first, leaving the other open. Fill greedily by DPS instead and one position takes the player another position needed.

It’s a maximum weight bipartite matching, so the whole squad gets solved at once with the Hungarian algorithm, which is trivial at ten positions. The concerns are banded into a single score: a bench on the open patch outranks a stale bench, which outranks a bare assignment, since somebody meaning to play a position beats leaving it empty. Within a band, the crew that puts up the most DPS wins. Candidates are ranked in signup order when weights are equal, so the same evening and comp always produce the same squad.

The difference is easier to see than to describe. Here is a four-position squad with a roster you can change, filled both ways at once:

Try it: crew the squad

Signed up
Greedy, position by position
Solved as one matching

Toggle a player off to see what a thin signup list does to each approach. Turning Ovid on is the "just add another healer" fix, and the two agree again. Scoring is banded exactly as described above, so a stale bench still beats a bare assignment, and a bare assignment still beats an empty seat.

A squad after Autofill: each crewed position marked with the bench it rests on or with "assigned", and a summary reading "Crewed 9 of 10 seats, 6 benched, 3 assigned but never benched"

Every position says what it rests on, which matters more than it sounds. “Assigned but never benched” is the guild’s own to-do list, falling out of a feature that wasn’t built to produce one.

A comp can also be imported from a raid log. That reads the squad and their subgroups out of a dps.report or Wingman log, then matches accounts to the roster and specs to builds. “Which build” can’t come from the spec alone though, because power and condition variants share one, so it comes from the weapons the position’s damage came out of, reusing the same machinery the off-weapon check needed. Who was playing quickness and alacrity gets worked out from boon generation, because what class somebody was on tells you very little about that.

One deployment, several guilds

The biggest architectural change since the first version is multi-tenancy: several guilds on one deployment, each seeing only its own roster, benches, assignments and comps, with a shared build catalogue maintained centrally.

The same dashboard under a second guild, wearing its own name and accent colour

The visible half of that is cheap and was still the first thing to break. A guild’s name and accent colour started out as NEXT_PUBLIC_* environment variables, which Next inlines at build time, so one bundle could only ever carry one guild’s name. Obvious in hindsight. Branding lives on the guild’s own row now and resolves per request.

The naive version of this is a tenant_id on everything. What it actually needs is four categories of data with different owners. The catalogue (patches, builds, official benches) is global, because those are facts about the game and identical for everyone. Selection is per guild: which parts of the catalogue a guild shows, plus its own private builds. Guild owned things like assignments, comps, plannings and achievements carry a tenant_id. And identity, meaning players and their personal benches, stays global, because a GW2 account and a dps.report log are facts about the world too. Tenant scoped player rows would give a dual member two identities, two uploads of the same log, and two places to apply a rename.

Visibility lives on the membership join instead, and it fits in one sentence:

You can see a personal bench if its player shares your active guild, and its build is in that guild’s selection.

Both halves matter. The first stops guild B seeing guild A’s roster. The second stops a dual member’s bench on one of MCA’s private builds from showing up on guild B’s site.

Enforcing it is where things get uncomfortable, because Aurora DSQL has no foreign keys and no row level security. Nothing catches a cross-guild read except the where clause the query remembered to include. Three rules keep it honest:

  1. Scope is a parameter, never resolved inside a query. This one is a real trap. Next’s unstable_cache derives its key from the wrapped function’s arguments, so a cached query that looked up its own guild would bake the first caller’s data into an entry that then gets served to everybody. It would hand out the wrong rows without throwing anything, and you’d have no way of noticing. A test enforces the rule mechanically by forbidding the query layer from importing the ambient tenant helpers at all.
  2. Scope is a union rather than a string. Only the tenant arm is constructible today. The global arm exists purely so the compiler will find every call site if I ever add a public view.
  3. Every scoped query has an isolation test. An integration suite seeds two guilds and asserts each query returns exactly one guild’s rows. A query without one isn’t finished.

Nothing lists the guilds either. There’s no directory and no “who uses this” page, and the switcher only shows guilds you belong to. Once a guild’s name is on a public page you can’t take it back.

Tournaments are tenants too

The guild also runs an open community draft tournament, and my first reading was that a cross-guild competition needs a third visibility mode somewhere between “my guild” and “public”. It doesn’t. A tournament is just a tenant whose members happen to come from many guilds, and the sentence above already says the right thing: entrants from six guilds can scout each other inside the tournament while their home guilds stay invisible to one another, and nobody’s private builds leak into an open competition.

One thing does change. Because personal benches are global, merely joining a tournament tenant would expose an entrant’s whole bench history the moment they arrived, and somebody who only signed in to read the rules never agreed to that. So on a tournament, visibility keys on an approved signup instead of bare membership. Submitting the application is the moment of consent, and the form says plainly that entering means your logs are visible to everyone else in the event. That way no captain ends up drafting blind either.

A few smaller decisions I liked:

  • A captain is someone who owns a team row, not someone with a Discord role. Roles are per server and captains are per edition, so the role version means somebody has to strip and reassign a hundred of them every year, and a forgotten one is a standing privilege bug.
  • Draft picks are append-only with a monotonic index. The board is derivable, undo is just a delete, and snake order falls out of arithmetic instead of being stored. Two captains clicking at once is settled by the primary key on (draft, pick number): DSQL is optimistically concurrent, so the second write aborts on its own and I never had to add a lock or a queue.
  • A pick has a visible countdown and no auto-pick. When it hits zero nothing happens except that everyone watching knows the captain is stalling, and on a stream with a host that’s the enforcement that actually works. Auto-pick would need a definition of “best available player”, which has no defensible answer and would eventually hand somebody a player they didn’t want, live.

Live updates during a draft are a polled version counter, one cheap row read a second, instead of SSE. Lambda response streaming through OpenNext is fragile enough that I didn’t want a live broadcast leaning on it, and with the auth wall up the audience is about ten captains and one OBS source anyway.

The bracket only stores the first round’s sides. Every later side is just the winner of the two matches feeding it, so storing one would be a second answer that can disagree with the first, and taking a result back needs no cascade. That rule came out of a bug: an empty slot in a later round usually means the match feeding it hasn’t been played, not nobody is coming. Read the first as the second and the first semi-finalist to win gets crowned champion.

A tournament bracket: two semi-finals with clear times, the slower team struck through, and the winners carried into the final

Each side’s clear time is submitted on its own and the two together decide the match, so one person can check one team without waiting for the other, or seeing it. A single time on its own decides nothing, and a half submitted match just stays undecided instead of showing somebody in the lead.

Tournaments are also the one place where throughput actually matters. A guild uploads a few logs a week. A bench challenge with a few hundred entrants means a few hundred dps.report fetches and parses, most of them probably bunched into the last evening before the deadline. Three rules sit on that path, all of them before the fetch, because refusing after it costs exactly what refusing was supposed to save: a sliding allowance of 30 submissions per player per 10 minutes, counted from the rows themselves so it holds across however many Lambda instances are running; one entry per log URL, by anyone; and a cooldown on re-checking, which writes no new row and would otherwise be an unmetered fetch loop.

Auth, for free

There’s still no user system. Access is Discord OAuth gated on guild role membership. Signing in fetches your Discord guilds, intersects them with the registered guilds in the database, and reads each one’s role IDs off its row. That’s what lets one deployment serve several guilds, and lets a dual member hold a different standing in each. There are three tiers: full members, trials who can submit and edit only their own benches, and guild friends who see even less. None of it is hardcoded, so the whole thing can be forked and pointed at any guild. Locally there’s a dev login provider so you don’t need a real Discord account, and it’s hard disabled in production, since the app refuses to boot if it sees the dev flag next to a production database.

Tournaments added one more identity check, because prizes go to a GW2 account and it has to be a real one. A GW2 API key gets used exactly once, at verification time, to fetch the account and match the name, and is then thrown away. It never gets persisted. Verification is a one time answer, not an ongoing capability, and a credential into somebody else’s system is the one thing I’d rather the database never contain.

Architecture and the $0.40 bill

The whole thing is serverless and scales to zero. Next.js 16 (App Router) with Drizzle ORM, deployed via OpenNext onto Lambda behind CloudFront, talking to Aurora DSQL, which is serverless, distributed Postgres.

flowchart LR
  User --> CF[CloudFront]
  CF --> Lambda[Next.js on Lambda]
  Lambda --> DSQL[(Aurora DSQL)]
  Lambda --> S3[(S3 log store)]
  Lambda --> SM[Secrets Manager]
  Lambda -- getJson --> DPS[dps.report / Wingman]
  EB[EventBridge weekly cron] --> Lambda
  Lambda -- webhook --> Discord

The detail I’m quietly proud of: DSQL authenticates with IAM over a public endpoint, so the app needs no VPC, which means no NAT Gateway. That’s the line item that quietly dominates most “serverless” bills. Secrets live in Secrets Manager and get fetched at runtime instead of baked into the Lambda environment, so they never sit in plaintext config. A weekly EventBridge cron nudges players who still owe a bench and fans out to each guild’s own webhook.

What I got wrong the first time round was storage. I kept the raw dps.report JSON in the database, and those blobs are megabytes each. DSQL is index organized, so a row’s data sits in the index and every scan over the table reads the blobs off storage whether the query wanted them or not. They completely dominated ReadDPU. They live in S3 now, gzipped and keyed by bench ID, with a plain filesystem store standing in locally. Clearing out the old ones came with a footnote, because Aurora DSQL has no DROP COLUMN. The migration just nulled the values out, which is the part that actually reclaims the per scan cost, so the empty column is still sitting there, unreferenced. Not pretty, but harmless.

The result is that an idle instance costs roughly $0.40 a month, which is a single Secrets Manager secret. Everything else (Lambda, CloudFront, DSQL, S3, logs) is pure pay per use, and a low traffic guild mostly stays inside the free tier.

It’s open source under MIT and configurable for any guild, not just mine. Code is on GitHub.