Live: https://keivanmalhani.github.io/fireteam-report/
Compare a Destiny 2 fireteam's raid and dungeon clears and get a ranked list of what to run tonight.
Raid Report tells you what you did. This tells you what to run tonight.
Every other Destiny stats site is built around one player. You look up your own name, you see your own clears, and that is the end of it. That does not answer the question a group of friends actually asks on a Tuesday evening, which is "what should we run, given who showed up". Answering that needs everybody's numbers side by side, and then it needs an opinion about them.
Get two to six players in, by signing in, by loading a clan roster, or by typing Bungie Names, and you get:
- one sentence saying what to run tonight, before any table or number
- the top pick as a card of its own, then the runners up in order, each with one sentence saying why
- a matrix of who has cleared what, folded away under the answer rather than in front of it
- per player totals, distinct raids cleared, and the activity they run most
- a link that loads the same fireteam for anyone you paste it to
- a plain text summary sized for a Discord message
- a 1200x630 share card of the top pick, which is also the site's og:image
The ranking is the point. It is an opinion, not a sort:
| Rule | Fires when | Why it ranks there |
|---|---|---|
| Sherpa run | Exactly one player has no clears, everyone else has at least one | Best thing a full fireteam can do with an evening. The site names the player. |
| Everyone's first | Nobody in the fireteam has cleared it | Nobody is bored and nobody is being carried. |
| Speedrun | Every player has five or more clears | Fast, clean, low friction. |
| Rusty | Everyone has cleared it, but the fireteam total is low | A warning. Expect it to be slow. |
| Lopsided | One player has more clears than everyone else combined | A warning. That player will end up calling the whole thing. |
Sherpa runs rank first, then everyone's first, then speedruns. Rusty and lopsided sit below those because they are warnings, not suggestions. The site says this on the page too, so the order is not a black box.
The displayed list will not show the same verdict more than twice in a row; it pulls the next different verdict up instead. A fireteam runs one thing tonight, so the fourth best sherpa run tells you almost nothing the first one did not, while the best speedrun tells you something new. This is display order only, kept in a separate function from the ranking: it never changes which activities are recommended or what verdict each one gets, and the order inside a verdict is preserved.
The engine is a pure function in src/recommend.ts. It takes a matrix of
numbers and returns ranked recommendations. It does no fetching, so it is
tested directly against fixture matrices, including the cases where each rule
must not fire.
The page is arranged so the answer arrives before the evidence. The first thing
on it is one sentence -- "Run Vault of Glass and get Ana their first clear." --
generated by headline() from the same ranked list everything else uses. Under
it is the top pick as a single large card, then the runners up, then the
ranking rules behind a disclosure, then the matrix behind another one. Somebody
who has never seen the site should be able to read the first line and leave.
Nothing was deleted to get there; the grid, the tally and the explanation are
all still on the page, one click down.
The list of raids and dungeons is not hardcoded. On each visit the site reads Bungie's manifest, pulls the activity definitions, and keeps everything tagged with activity mode 4 (raid) or 82 (dungeon).
That matters because Destiny ships new raids. A hardcoded list needs a commit and a deploy every time; a derived list picks up a new raid the moment Bungie publishes the manifest entry, with no work from anyone.
The derived list is cached in localStorage against the manifest version
string, so the definition file is downloaded once and then not again until
Bungie publishes a new version. A snapshot is committed in
src/fallback-activities.ts and used only when bungie.net cannot be reached,
so the page still renders when the API is down. A test re-derives that snapshot
from committed raw definitions, so it cannot drift away from the collapsing
rules without CI noticing.
Bungie ships one definition per difficulty, so a single raid arrives as
"Vault of Glass: Standard", "Vault of Glass: Master" and
"Vault of Glass: Challenge Mode". Those collapse into one row with the tiers
tracked underneath. The suffixes stripped are Standard, Normal, Master,
Legend, Expert, Contest, Prestige, Challenge Mode and Level NN.
Pantheon is grouped separately, because it carries the raid mode flag but is a
boss rush rather than a raid. Bungie tags Crota's End as both a raid and a
dungeon; raid wins, so it appears once.
As of manifest version 244213.26.06.29.2000-1-bnet.65583 that is 37 raid names and 21 dungeon names collapsing into 14 raids, 9 dungeons and 4 Pantheon encounters.
The site opens in demo mode against a committed fixture fireteam, so the whole thing works before you have done anything at all. There are three ways to point it at real people, and none of them asks anybody to create anything.
Load a clan. One box, one exact clan name, and then a roster you tick people off. This needs no sign-in and no key of your own: clan reads are public. The people who are online right now sort to the top and the first six are ticked already, because that is usually the answer.
Bungie matches the whole clan name, not part of it. Capitals and surrounding spaces are forgiven and spelling is not, so "Math Clas" finds nothing. The site says so where you type it.
Sign in with Bungie. One button. It goes to bungie.net, comes back through d2-auth, and then "Add me" puts you in the fireteam with no name to type, and "Use my clan" skips knowing the clan name. That is all it buys: every stats call this site makes works signed out. Bungie issues no refresh token to a public client, so the session lasts an hour and cannot be extended, only replaced; the page says how much of it is left and offers the button again when it runs out.
Type Bungie Names. Two to six of them, Guardian#1234. No sign-in, because
building a fireteam out of your friends means naming people who are not you.
The site's own Bungie API key ships in the built JavaScript. That is not an oversight: a browser has to send one with every request, so there is nowhere to put it that a reader cannot reach, and every static Destiny tool works this way. The only thing it protects is a rate limit, which is therefore shared between everyone using the site. That is why the picker caps at a fireteam rather than offering to read a hundred people, why players are read three at a time instead of all at once, and why the panel tells you what a load will cost before you press the button.
Reading one player is one profile call plus one per character, so two to four requests; a six person report is twelve to twenty four. Somebody picked off a roster costs one less than somebody typed in, because the roster already knows which membership they are and the name search can be skipped.
localStorage holds one thing: the cached activity list. The session, when
there is one, lives in sessionStorage and is written by d2-auth, not by this
site.
- It will not read private accounts. If someone has their Destiny privacy set so stats are hidden, the site says so in their column instead of showing a zero, because "no clears" and "would not say" are different answers.
- It will not tell you what is currently in the game. The manifest still contains sunset content such as Leviathan and Scourge of the Past. They show up as rows. There is no reliable "currently available" flag in the activity definitions, so filtering them would mean hardcoding a list, which is exactly what the manifest approach avoids.
- It does not know about anything except raids and dungeons. No strikes, no Crucible, no Trials, no seasonal activities.
- It does not know when you cleared something. The aggregate stats endpoint gives totals, not dates, so it cannot tell a clear from last night from one in 2018. "Rusty" is inferred from low totals, not from time since last run.
- It does not check whether you actually have the DLC that an activity belongs to.
- It will not read a clan you cannot name. Bungie's clan search matches the whole name only, so there is no browsing and no "clans like this". Signing in is the way round that, and it is one click.
- It has no accounts and stores nothing of its own. Close the tab and the only trace is the cached activity list in your own browser, plus a session that dies with the tab if you signed in. Share links carry the fireteam in the URL hash, which browsers do not send to servers.
- It is not affiliated with Bungie.
npm ci
npm test # vitest
npm run build # typecheck and bundle to dist/
npm run dev # local dev server
npm run derive # refetch the manifest and regenerate the fallback table
npm run ascii # fail on any non-ascii byte in a text file
npm run derive fetches the live manifest, rewrites
src/fallback-activities.ts and fixtures/activity-defs.json, and prints the
counts it derived. Run it when Bungie ships a new raid if you want the
committed snapshot to match.
The share card is rendered to public/og.png by scripts/render-og.mjs,
which is deliberately outside the build because it needs a native canvas:
npm install --no-save @napi-rs/canvas
node scripts/render-og.mjs
src/
activities.ts collapsing variants into one row per activity (pure)
recommend.ts the engine, and the one sentence version (pure)
permalink.ts fireteam to and from the URL hash (pure)
bungiename.ts Name#1234 parsing (pure)
aggregate.ts summing stats across characters (pure)
clan.ts roster paging, picking, and what it costs (pure)
signin.ts the session as UI state and error copy (pure)
discord.ts the copy for Discord text (pure)
card.ts the 1200x630 share card, layout and drawing (pure layout)
manifest.ts manifest fetch, version keyed cache, fallback
bungie.ts API client, clan endpoints, per player lookup
auth.ts vendored from d2-auth, do not edit here
fallback-activities.ts generated snapshot, do not edit by hand
demo.ts demo mode
ui/ DOM rendering, including the clan picker
main.ts entry point
fixtures/
activity-defs.json real raid and dungeon definitions, for the tests
demo.json the six player demo fireteam
tests/ vitest suite
public/
og.png the demo fireteam's card, rendered by hand
scripts/ manifest derivation, demo generation, og image
The pure modules hold everything worth testing and none of them import fetch.
GET /Platform/Destiny2/Manifest/usually answers without an API key, but not always. It intermittently returns HTTP 500 withApiKeyMissingFromRequest, so the site retries, attaches the key to every request, and falls back to the committed snapshot if it still fails.- The activity definition file it points at is served with
access-control-allow-origin: *and needs no key at all. - Per player endpoints do need
X-API-Key. Bungie reflects the request origin inaccess-control-allow-originand allowsX-API-Keyinaccess-control-allow-headers, which is what lets a static site on github.io call the API directly with no backend. - Clan reads need the key and no OAuth scope whatsoever, which is measured, not
assumed.
POST /GroupV2/Search/with{"name":...,"groupType":1,"creationDate":0,"sortBy":0,"page":0}finds a clan by its exact name,GET /GroupV2/{groupId}/Members/?currentpage=1returns the roster andGET /GroupV2/User/{type}/{id}/0/1/returns the clans one player is in. All three answerErrorCode: 1with nobody signed in. currentpageis 1-based and the roster page holds 100. A Destiny clan caps at 100, so a correct roster is normally one request, but the walk followshasMoreandtotalResultsanyway rather than assuming that.- Bungie returns ordinary application errors as HTTP 500 with a real
ErrorCodein the body. A private account, a clan that does not exist and an expired token all arrive as 500. Any retry rule written asstatus >= 500therefore retries every one of them, four times over, for the same answer.isRetriable()branches on the code and only lets the status decide when there is no code at all. The four codes that mean the sign-in is over, 99, 2111, 2123 and 2124, are never retried. - A player picked off a clan roster arrives with their membership already
resolved, so
SearchDestinyPlayerByBungieNameis skipped for them. That is one request saved per player. - A Destiny account holds up to three characters and aggregate stats are reported per character, so clears are summed across all of them.
- Every request has a deadline and the retry loop has an overall budget. A refused connection fails fast, but a connection that is accepted and then never answered would otherwise hang forever and never reach the fallback, which leaves the page on a spinner with no way out. Captive portals and flaky mobile connections do exactly that.
See SECURITY.md. Short version: report privately through the Security tab, there is no server and no key of its own, and the most recent tagged release is what is supported.
MIT. See LICENSE.