Underground 2 Revived
Chrome과 Firefox에서 Need for Speed Underground 2를 WebGPU로 두고 팬 프로젝트입니다
Riftbound 카드 보유 수·위시리스트·세트 완성도까지 추적합니다. 데이터는 Riftcodex API 제공
Track your IRL Riftbound (LoL TCG) card collection - owned counts, wishlist and per-set completion. Card data from the Riftcodex API.
게임JavaScript · CSSGitHub @MichaelPlakiotis온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Tracker for an IRL Riftbound (Riot's LoL TCG) card collection.
Live: https://michaelplakiotis.github.io/riftbound-collection/
No server, no build step, no install — opening index.html locally works too.
Your collection is stored in
localStorage, which is per-origin. The live site and a local copy therefore keep separate collections; use Export/Import to move data between them. Pick one as home — the phone-friendly live site is the obvious choice.
+ / − / type a number — copies you own. Owned cards light up; unowned
are dimmed so gaps are obvious at a glance. The second, quieter stepper below
counts foils, which are priced separately — see Foils.
☆ — wishlist flag.
Search — matches card name, rules text, keywords and card ID. Ctrl+F
(Cmd+F on Mac) jumps here instead of opening the browser's find bar, which
would only search the cards currently on screen. Esc clears, then unfocuses.
Filters — set, domain, rarity, type, plus All / Owned / Missing / Wishlist. "Missing" is your want-list for a given set. Cards spanning two domains (new in Vendetta) match either one. Promo sets are hidden by default and excluded from the overall completion figure; picking a promo set explicitly still shows it.
Rune colours on the domain filter — the domains are the runes, and a rune
is a colour before it's a word, so the filter carries one. A chip beside the
dropdown shows the domain you've picked in the same colour the rules text and
the card dots draw its rune; with the filter off it shows the game's rainbow
rune, "a rune of any domain". The names inside the open dropdown are tinted to
match where the browser allows it — only color survives inside an <option>,
and Safari drops even that, so the chip outside is what always works.
Sort — the last dropdown in the filter bar reorders the grid: price high to
low or low to high, quantity high to low, energy cost either way, by card
type (Legend, Unit, Spell, Gear, Rune, Battlefield — the order the deck panel
groups them in, with each type's own curve inside it), or name. Quantity counts
every physical copy, foils included — the number on the tile's badge — so it
opens on the stacks you have most of, which is the pile you trade out of. It
has no low-to-high twin on purpose: reversed it would open on a thousand cards
you own none of. The grid re-sorts on the next filter change rather than while
you tap +/−, so a stack you're counting out doesn't slide away under the
cursor. Cards with nothing to sort on — a card
with no sales data, a Rune with no energy cost — always sink to the bottom
rather than flipping to the top when you reverse the direction. Default is the
order the sets were printed in, which also breaks ties everywhere else. The
choice is remembered with the other preferences; Reset puts it back. Price
orders only appear once a price sync has been run.
Collection worth — total market value of everything you own, in the header,
and the one control for the detail panel. Click it to show or hide: the
breakdown (what the total is made of and the cards carrying most of it) and
per-set progress bars — owned / total, percent, and value per set. Promos count
here even though they sit outside set completion; they're still money on the
shelf. Open or closed is remembered under riftbound-prefs-v1, kept separate
from the collection so exports stay portable. Without a price sync the chip
reads "Set progress" and opens the same panel minus the money.
Currency — the dropdown beside the worth switches every figure between USD, EUR and GBP, and is remembered with the other preferences. It changes the market, not just the units: EUR shows Cardmarket's own European prices, while USD and GBP show TCGplayer's. See Two markets, not one currency — the totals deliberately don't reconcile at any exchange rate.
Set rows are also filters — click one to narrow the grid to that set, click it again for everything. It drives the same filter as the set dropdown.
On narrow screens (≤760px) everything except the brand and the search box folds behind a burger button, which opens as a dropdown over the grid — the full header was costing a phone most of its first screen of cards. The button carries a count of the filters currently applied, so a narrowed grid is never a mystery while the controls that caused it are out of sight.
Prices — market price under each card, the value of your stack once you own more than one, and the move since the last sync. A move has to clear both 3% and 5¢ to show, otherwise a penny of rounding on a 5¢ common reads as ±20%.
Click a card image — full details: art, cost, Might, Power, domains, keywords, tags, formatted rules text, flavour, both market prices, and links out to Cardmarket and TCGplayer. See Card details.
Collectors — the button beside the search box. Lists everyone who has made their collection public; pick one and their cards replace yours in the grid, so the search box and every filter and sort are suddenly working on their binder. A bar across the top compares their totals with yours. Read-only throughout. Needs an account on both sides — see Public profiles.
Open a pack — booster simulator. Pick a set, tear the wrapper, click through the cards one at a time. See below.
Build deck — generates a legal, playable deck from cards you own. See below.
Decks — decklists you keep, in the 1 Teemo, Swift Scout [OGN-263] format
the deck sites trade in. Paste one in, save the generator's, and see how much
of each you own. Synced to your account when signed in. See
Saved decks.
Export / Import — localStorage is per-browser and gets wiped if you clear
site data, so export occasionally. Export opens a format menu (hover or click,
and it unfolds inline inside the burger panel on a phone):
| Format | What it's for |
|---|---|
| JSON | Full backup. The only format that carries this app's own card ids, so it's the only one that can't lose a printing |
| CSV | Spreadsheets. One row per printing, so a card you hold in both finishes gets a normal row and a foil row: card id, set code, printed number (with its treatment marker, 007a), quantity, foil, wishlist flag, rarity, type, domains, unit/total price in the currency currently selected, and the name. Each row is priced as the printing it is. UTF-8 BOM so Excel doesn't mangle card names. It is the only export with prices in it, and the only one no other tracker recognises — see below |
| riftbound.gg | Four columns shaped for riftbound.gg's collection importer, one row per printing. See below |
| Other trackers | The RiftCore collection shape, which is what the rest of the ecosystem sniffs for — one row per card with Standard Qty and Foil Qty in their own columns. This is the file to hand OpenRift, and through it Piltover Archive and RiftMana. See Exporting to other trackers |
| Text | Readable list grouped by set — 3x Ashe, Frost Archer (OGN 012), with [1 foil] appended when some are — and a wishlist section at the end. The number carries its treatment marker (OGN 007a), which is what lets this list be read back in full |
| TCGplayer mass entry | 3 Ashe, Frost Archer lines for bulk-add boxes. Owned copies only, foils included in the count; a wishlist card has no quantity to enter |
Everything but JSON identifies cards the way they're printed — set code plus zero-padded collector number — rather than by this app's internal ids, which is what makes them readable somewhere else. , and a good deal it never wrote; see .
Import used to read one format — its own JSON — and apply it one way, by replacing everything. It now takes anything a Riftbound tracker or a spreadsheet is likely to produce, shows what it made of the file, and asks how to apply it.
It reads roles, not formats. Every collection CSV in this ecosystem is the
same handful of columns under different names: OpenRift's Finish, Piltover
Archive's Variant Label and our own Foil all answer one question, and
Standard Qty/Normal Qty are the same column twice. So rather than four
vendor parsers, there is one reader that finds its own header row — anywhere in
the first twelve, which is what steps over RiftCore's preamble — and works out
what each column means. A row has to name something identifying and
something countable before it counts as a header, which is what keeps a title
line or a totals row from being mistaken for one.
The upshot is that a tracker nobody here has heard of imports too, as long as it names its columns plainly. Verified against files in all four shapes plus a semicolon-separated sheet and unpadded codes — every one read, every card resolved:
| File | Read as | Result |
|---|---|---|
| OpenRift | Card ID + Finish + Quantity | 5/5 cards, foils kept |
| Piltover Archive | Variant Number (with its -Foil suffix) | 5/5 cards, foils kept |
| RiftMana | Normal Qty + Foil Qty | 5/5 cards, foils kept |
| RiftCore | preamble, then split quantities | 5/5 cards, foils kept |
| A semicolon-separated sheet | separator sniffed, not assumed | 5/5 cards, foils kept |
3 Ashe, Frost Archer lines | a plain card list | 5/5 cards, no foil column to read |
Codes are matched leniently, since no two tools spell one the same way: a
trailing -Foil or -p, RiftCore's S where the game prints a star, a number
that was never zero-padded, and a rune's printed R05 beside the plain 005 our
own exports write all land on the same card.
69 codes are shared by more than one of our cards, so a code alone can't always decide. Two things break the tie before anything is guessed:
9x Fury Rune (Alternate Art) (OGN 007), where the number can't tell the two
printings apart but the name can. A reprint in another set is not allowed to
win an argument it isn't in.OGN-007 with a Card Number of 007a; RiftCore does the
reverse, OGN-007A beside a bare 007. Both columns are tried, and carrying a
treatment marker is what decides which goes first.Whatever is left genuinely ambiguous goes to the earliest-printed card and is reported as a count in the review, rather than resolved silently.
The review shows what the file holds, what didn't match, and what each choice would do to the numbers — then offers two:
Rows that resolve to the same card add up rather than overwriting, so a normal row and a foil row for one card — how our CSV and OpenRift's file both spell a stack — arrive as one entry with both counts.
Exporting the whole 1411-card catalogue at 2 normal + 1 foil and importing it straight back:
| Format | Cards back exactly | Copies back |
|---|---|---|
| JSON | 1411 / 1411 | 4233 / 4233 |
| Text | 1411 / 1411 | 4233 / 4233 |
| CSV | 1379 / 1411 | 4233 / 4233 |
| Other trackers | 1379 / 1411 | 4233 / 4233 |
| riftbound.gg | 1091 / 1411 | 4233 / 4233 |
| TCGplayer mass entry | — | 4233 / 4233, all as non-foil |
Every copy comes home in every format; what a lossy one costs is which printing it lands on. The 32 the CSV and the tracker file give up are the genuine collisions — Organized Play waves that share a collector number and a name, where nothing in the file can separate them. riftbound.gg's four columns carry neither a treatment marker nor a treatment in the name, by design, because their catalogue files one entry per collector number. The mass-entry list has no syntax for a printing at all, so it comes back as one non-foil pile.
Two exports changed to make this work, both in the direction of being more
correct anyway: the CSV's Card Number and the text list's (OGN 007) now carry
the treatment marker the way the grid prints it — 007a — since without it a
card and its alternate art are the same row.
Their importer takes a CSV or plain text file up to 2 MB, detects the columns itself — there is no manual mapping step — and then lists whatever it couldn't recognise for you to resolve by hand. Two things about it drive the export:
card id, quantity, foil, name
(plus synonyms — qty, count, owned, cardname…). Everything else in the
row is ignored, and matching is an exact, case-insensitive lookup on their card
id or full name. No fuzzy matching."Ashe, Frost Archer" still shifts every column after it, and if the quantity
column lands on text, the row parses as 0 and is dropped silently — not even
reported in the unresolved list. 75 of our card names contain a comma.So both CSV exports put the name in the last column, where a comma in it can
shift nothing, and lead with a Card ID holding their identifier — set code, a
dash and the padded number, OGN-066. Names are written without our treatment
suffix ((Alternate Art), (Metal)), since their catalogue lists one entry per
collector number and a suffixed name matches nothing.
Measured by running their own parser (lifted from their bundle) over an export of all 1320 cards: 1312 import, and the 8 that don't are Organized Play and general promos their catalogue files under different codes entirely. They show up in their "Needs attention" list, where you can point each at the right card. Before this shape, the same file imported 1013 cards, dropped 75 rows without saying so, and put 232 in the unresolved pile.
Every other Riftbound tracker refused our CSV, and it wasn't the columns — it was the first line. Importers in this ecosystem sniff the format before they read a row, and each one is keyed to a signature it already knows:
| Format | Recognised by |
|---|---|
| OpenRift | an Art Variant column |
| Piltover Archive | a Variant Number column |
| RiftCore | the literal first line RIFTCORE COLLECTION EXPORT |
| RiftMana | a Normal Qty column |
Our spreadsheet CSV carries none of those, so it isn't "a CSV they mis-parse" —
it's a file they decline to open at all. Verified by running
OpenRift's own detectImportFormat over each of our
exports: the CSV, the riftbound.gg file, the text list and the TCGplayer list
are all unrecognised. That is the whole of the bug.
The Other trackers export writes the RiftCore shape, which is the one worth targeting for two reasons. It's the widest door — OpenRift reads all four formats and writes all four back out, so this one file also reaches Piltover Archive and RiftMana. And it's the only one whose foil model is already ours:
RIFTCORE COLLECTION EXPORT
Exported from Riftbound Collection on 2026-08-30
Card ID,Card Name,Set,Card Number,Type,Rarity,Domain,Standard Qty,Foil Qty
OGN-001,Blazing Scorcher,Origins,001,Unit,common,fury,2,1
OGN-007A,Fury Rune,Origins,007,Rune,showcase,fury,9,0
OGN-299*,Kai'Sa - Daughter of the Void,Origins,299,Legend,showcase,fury / mind,1,0
Two normals and one foil is three cards, and the collection has always stored
it that way — q and f, two counts on one entry. Our CSV flattens that into
two rows sharing a name, told apart only by a Foil column. A reader that
doesn't know that column sees the two rows and has no good option: fold them and
you get five copies, keep the first and the foils vanish. Standard Qty and
Foil Qty say it in one row, in the same shape it's stored in, with nothing to
infer.
One thing does change on the way in. Rare, Epic and Showcase cards have no non-foil printing in Riftbound, so these readers take the standard count on one as foil too — a plain copy of an Epic arrives foil. Both counts still land on the card, so a total is never lost.
Measured by transpiling OpenRift's own import-parsers.ts and running an export
of the whole catalogue at 2 normal + 1 foil through it:
PR set. Their card-id pattern takes
exactly three uppercase letters for a set code and PR has two, so there is no
spelling of those promos their reader accepts. They arrive as named
unrecognised rows rather than as the wrong card, which is the better failure.OGN-007A reads back as
alt-art and OGN-299* as overnumbered, rather than collapsing onto the plain
card the way the riftbound.gg export has to.OPP,
where Riftcodex numbers several Organized Play waves alike, plus runes numbered
R01 colliding with card 1. Set code and number is all this format has, so
those land together on one printing. The copies are right; which card they're
filed under may not be.Chasing this turned up a bug in the sync. variantOf pulled a printing's
treatment marker off the end of the id with /^\d+(.+)$/, which needs at least
one character after the digits — so on a plain ogn-009-298 it backtracked,
read the number as 00 and the marker as 9. 1151 of 1411 cards carried a
phantom marker, and the grid printed their collector numbers with the last digit
doubled: 0099 for card 9. The pattern now insists the marker be a letter or a
star (/^[a-z]*\d+([a-z*]+)$/i), which leaves all 198 real markers — a, b,
c, * — untouched and clears the rest. data/cards.js was re-derived in place
rather than re-synced, since the id it reads is already in the file.
Pick a set, tear the wrapper open, and click through the cards one at a time —
Space, Enter or → work too, and Reveal all skips to the end. Rares,
Epics and Showcases arrive with a glow, a shockwave and a rising chime; the
chime is synthesised from oscillators at the moment it plays, because the site
ships no binary assets and makes no network calls. The 🔊 button mutes it and is
remembered.
Nothing you open is saved. No pull touches your collection or
localStorage, and the summary's session column is counted in memory and gone
on reload. The odds are the only thing that outlives the pack — and they're
computed from the slot table rather than recorded.
The 14-card configuration Riot published for Origins:
| Slot | Count | Contents |
|---|---|---|
| Common | 7 | |
| Uncommon | 3 | |
| Rare or better | 2 | foil; 85% rare, 11% epic, 4% showcase |
| Foil | 1 | any rarity; usually common or uncommon, can upgrade |
| Rune or token | 1 |
Riot publishes the per-pack rates but not the per-slot table behind them, so
the slot odds above are chosen to reproduce the rates that are published: two
rare slots at 11% and one foil slot at 4% put an Epic in
1 − 0.89² × 0.96 = 24% of packs and a Showcase in 1 − 0.96² × 0.99 = 8.8%,
against the published 1 in 4 and 1 in 12.
Verified against 20,000 simulated packs per set. The summary reports what the
model actually does, not the target, so the figures always describe the pack you
just watched.
Sets adapt to what they printed:
Base slots draw plain printings only — (Alternate Art), (Signature),
(Overnumbered) and friends live in the showcase pool, which is what a showcase
hit is. Runes and the five Token cards are held back for the last slot;
without that a pack could open with eight runes, or put the Recruit token in a
slot that should hold a real card.
Picks the Legend your collection best supports, then builds around it. Every constraint in the official Core Rules §103 is enforced exactly:
| Rule | Enforced |
|---|---|
| 103.1.b | One Champion Legend; its two domains set the Domain Identity |
| 103.2.a | Single-domain cards need their domain in the identity; multi-domain cards need all of theirs |
| 103.2.b | Main deck of exactly 40, Chosen Champion included |
| 103.2.c | Chosen Champion is a champion unit sharing the Legend's champion tag — signature units don't qualify |
| 103.2.d | Max 3 copies of a named card — 1 for cards with Unique |
| 103.2.e | Max 3 Signature cards in total, all carrying the Legend's tag |
| 103.3 | Separate 12-card rune deck, all within the identity |
| 103.4 | 3 battlefields, no two sharing a name |
On top of the rules, it only ever uses copies you actually own — counted across printings, since an alternate art is the same named card.
Two data quirks it has to work around, both verified against the full catalogue:
Jayce, Man of Progress and Jayce - Man of Progress are one card, and
16 pairs are affected — without merging them the builder would happily run six
copies. All 16 agree on type, domains, energy, might and power, so nothing
distinct gets conflated.Draven, Showboat loses Noxus), so tags
are unioned across printings.What isn't exact: which of your legal cards are best together. That's approximated from rarity, champion-tag synergy, text that names your champion, energy curve, colour requirements and keyword density, aiming for ~23 units and a curve peaking at 3–4 energy. Runes are split in proportion to the coloured power your chosen cards actually demand, with a floor of 4 so the off-domain stays castable. Treat the list as a strong first draft, not a tuned decklist.
If your collection can't reach 40, you get the best partial deck plus exactly what's missing — which doubles as a shopping list.
Scoring cards one at a time can't see the thing that actually makes a Riftbound
deck work: nearly every archetype is a loop with two halves, and neither half is
worth anything without the other. A [Level 3] body with no way to gain XP is a
vanilla unit; a discard outlet with nothing that cares about a full trash is
card disadvantage. In isolation both halves score like ordinary cards of their
rarity, so a per-card ranking takes them in whatever order rarity happens to
put them.
Nine loops are recognised, each declaring both of its halves:
| Theme | Sets it up | Pays it off |
|---|---|---|
| Discard & trash | discard N | "when you discard", "you've discarded", "from your trash" |
| XP & levelling | [Hunt N], "gain N XP" | [Level N], "spend N XP" |
| Empower | [Empower], "empower …" | [Empowered], "disempower" |
| Gear & equipment | Gear cards, [Equip], Equipment tag | [Weaponmaster], "gear you control" |
| Rune ramp | [Add], "channel N runes" | "control 7 or more runes", "for each rune you control" |
| Tokens & swarm | cards that play tokens | [Legion], "other friendly units" |
| Spell slinging | Spell cards | "when you play a spell", "with a spell" |
| Hidden cards | [Hidden] | "from face down", "when you hide" |
| Sacrifice | "kill a friendly …" | [Deathknell] |
Matching runs against keywords, tags, type and the rules text with reminder
parentheses stripped — reminder text restates the keyword it belongs to, so
leaving it in makes [Hunt 2] (…gain 2 XP.) register as an XP enabler twice and
every [Empowered] reminder register its card as an empowerer it isn't.
A theme is only pursued when your collection can field both halves — at least five enablers and three payoffs in copies you own — and the strongest two are picked, the second scored as a subplot at 55%. A theme your Legend's own text is built around is worth 60% more than any amount of depth in the pool: it's the one card in play every game. The deck panel shows what it settled on and how many cards ended up on each side; a lopsided pair is the interesting case, because it means the collection could only supply one end of the loop.
The greedy fill is fast and wrong in a predictable way: it commits to a card the moment it comes up in the ranking and never reconsiders, so a strong 3-drop that arrives after the 3-energy bucket filled is gone for good — even though the cards that filled it scored less.
So a second pass repeatedly trades the single copy whose replacement improves the deck most, until nothing does (capped at 24 trades; it converges well before that). "Improves the deck" is the part a card score can't express:
The trade count for the deck you're looking at is shown at the bottom of the panel.
Clicking any card image — in the grid, or a card name in a suggested deck —
opens its full art, cost, Might, Power, domains, keywords, tags, rules text,
flavour and both market prices, with the ownership steppers repeated so you can
edit the count without closing it. Rules text is reformatted on the way in:
Riftcodex ships it as one unbroken run carrying the game's own inline codes
(:rb_energy_3:, :rb_rune_calm:, :rb_might:), with no separator between
abilities at all, so the paragraph breaks are inferred, the codes become symbols
and reminder text is dimmed.
Two links out: Cardmarket, searching their Riftbound category, and
TCGplayer, which is an exact product link wherever the card carries a
product id. The Cardmarket query drops the punctuation between a champion and
their title — our catalogue writes both Vi - Destructive and
Kai'Sa, Survivor depending on the set, and neither separator is certain to be
the one Cardmarket printed, so Vi Destructive matches either way. Cardmarket
answers automated requests with a 403, so the exact query string is unverified
from here; a name that finds nothing still lands on their search page rather
than a 404.
Copy list produces the format Rift Atlas and the other importers parse:
Legend:
1 Kennen, Heart of the Tempest
Champion:
1 Kennen, Storm of Shuriken
MainDeck:
3 Teemo, Scout
2 Kennen, Storm of Shuriken
...
Three things their parser is strict about, each of which broke the first version:
Main Deck (39)
isn't a header — the count makes it fall through and be read as a broken card
line, which cascades into "every section is missing".MainDeck, so that Champion + MainDeck is exactly 40, not 41.Vi - Destructive and prefixes tags on some legends
(Yordle, Kennen - Heart of the Tempest); their catalogue contains no name
with a dash and none with two commas. The export rewrites the last " - " to
", " and drops anything before a comma to its left, which takes name
resolution from 762/941 to 939/941 against their real 1240-card
catalogue. The two that still fail are Vendetta cards missing from their
catalogue entirely.Verified by extracting Rift Atlas's own parseDecklist from their site bundle
and running generated decks through it: 10/10 accepted, zero errors, zero
warnings, and every card name resolving.
Decks in the header keeps the lists you build or paste, in the format every Riftbound deck site trades in — sections, counts, and the printing in brackets:
Legend:
1 Teemo, Swift Scout [OGN-263]
Champion:
1 Teemo, Strategist [OGN-121]
MainDeck:
1 Bone Skewer [UNL-139]
3 Consult the Past [OGN-083]
…
Battlefields:
1 Bandle Tree [OGN-278]
Runes:
7 Chaos Rune [OGN-166]
5 Mind Rune [OGN-089]
Sideboard:
2 Gust [OGN-169]
Paste one in and it's saved; unfold a deck and you get that text back; Copy
puts it on the clipboard. A deck built by the generator gets there through
Save deck in its dialog, split the way the format wants — the Chosen Champion
in its own one-card section, the rest of its copies in MainDeck, so the two
sections come to exactly 40.
What's stored is card ids and counts, not the text. The text is rendered on
demand, which is what lets a deck pick up a corrected card name from the next
sync-cards.mjs run, be counted against your collection, and be priced. Each
saved deck shows its Legend, the counts the rules care about, its market value,
and how much of it you actually own — the point of keeping decks in a
collection tracker rather than in a text file.
The parser is deliberately forgiving, because the lists people paste come from everywhere:
MainDeck:, Main Deck: and maindeck: are one thing. Sideboard,
Side, Rune Deck and the rest have aliases.<count> <name>, with 3x Name and a trailing [OGN-094] both
fine. Cards before any header are read as the main deck; a bare list of cards
is a deck, and refusing one for not saying so would be pedantry.# and // lines are comments. A heading naming no section we know is
reported rather than silently swallowed.Cards resolve through the same index the collection importer uses, so a
decklist and a collection CSV recognise a card by identical rules: the bracketed
code decides when there is one, the name settles it when there isn't, and
Teemo, Swift Scout finds Riftcodex's Teemo - Swift Scout because the
comparison throws punctuation away. Anything unresolved is counted in the toast
rather than quietly dropping the deck.
Checked by pasting the list above, storing it, and rendering it back: the text comes out byte-identical, the sections resolve to 40 main / 12 runes / 3 battlefields with nothing unmatched, and a generated deck survives the same round trip.
The generator's Copy list button is unchanged and still writes names without codes, for the reason in Copy list: Rift Atlas's importer accepts a bracketed code but treats an unresolvable one as a hard error, where an unresolvable name is merely looked up. Their catalogue and ours disagree about enough printings that names are the safer wire format. Saved decks are ours, read back by us, so they carry the code and are exact.
Locally under riftbound-decks-v1, and in Postgres when signed in. Unlike the
collection, decks are a row each:
| Collection | Decks | |
|---|---|---|
| Shape | one JSON document per user | one row per deck |
| Why | a couple of thousand entries always read and written together | each has a name, its own history, and gets edited on its own |
| Conflicts | newer updated_at replaces the local copy wholesale | resolved per deck, so renaming one can't roll back another |
Two details follow from that. The deck id is a UUID the browser picks, not
one Postgres assigns — a deck exists before an account does and keeps the same
identity when it arrives, so syncing compares ids rather than mapping between
two sets of them. And the decks table deliberately has no updated_at
trigger, unlike the other two: the timestamp has to mean "when this deck was
last edited" for the per-deck comparison to work, and a trigger would stamp
now() on every push, letting a device that had been offline for a week win
every comparison on arrival.
Deleting a deck leaves a tombstone — the id and the time. Without one, a deck you deleted here is just a deck the cloud has and this device doesn't, which reads as "adopt it", and every sync would hand back everything you ever threw away. Tombstones are cleared once a sync has carried the delete out, and expire after 90 days regardless.
Decks are private to their owner. Publishing a profile makes your
collection browsable and says nothing about your decks; there is no second
read policy on the table. Sharing them later would be an is_public column and
one more permissive policy, on the pattern read public collections already
sets.
Card data is baked into data/cards.js at sync time rather than fetched at
runtime. A local file works offline and costs nothing at page load, and browsers
can't call these APIs directly anyway — RiftScribe sends no
Access-Control-Allow-Origin header.
When a new set drops:
node sync-cards.mjs # ~30 s
node sync-prices.mjs # ~15 s — run this one whenever you want fresh prices
Set names and release dates come from the API, so a new set needs no code change.
Prices also refresh on their own: .github/workflows/sync-prices.yml runs
sync-prices.mjs every day at 09:00 UTC and commits data/prices.js when the
numbers actually moved. Both hosts deploy from main, so the commit is the
publish step. It's a workflow_dispatch too, so the Actions tab has a button
for an unscheduled run. A daily cadence also gives meta.previousSync and the
per-card p field real meaning — the deltas in the UI become day-over-day
movement instead of "since whenever I last remembered to run this".
Your collection lives in localStorage, not in cards.js, so re-syncing never
touches it. Quantities key off card ID, and IDs are stable across syncs — and
identical between Riftcodex and RiftScribe, so even switching data source back
would preserve your counts.
Riftcodex returns 1451 rows for 1320 actual cards, and the two causes need opposite handling:
Shen, Scourge of Shadows (Alternate Art) vs plain). 131 of these collapse.Yasuo - Unforgiven and
Yasuo - Unforgiven (Metal) are both opp-259-298. Different cards to own, so
both are kept and the second gets a ~2 suffix.Artwork can't separate these — every duplicate group shares one image URL — but TCGplayer IDs can: two distinct IDs means two real products, a stub has none. The base printing always sorts first and keeps the clean ID.
index.html markup
styles.css styles
app.js filtering, sorting, stats, prices, persistence, export/import, card detail, deck + pack UI, saved decks, visiting mode
deck.js deck generator — rules engine, synergy scoring and search — plus the decklist format's reader and writer, no DOM dependency
pack.js booster pack model — slot table and draws, no DOM dependency
cloud.js optional — accounts, collection and deck sync, and the session everything else reads
social.js optional — public profiles, the collector browser, ?u= links
supabase-config.js project URL and browser-safe key (placeholders = local-only)
supabase-schema.sql collections, profiles and decks — tables, RLS policies and the browse view; run once in the SQL editor
vendor/supabase.js vendored client, injected on demand
sync-cards.mjs pulls cards from the Riftcodex API
sync-prices.mjs pulls market prices from TCGplayer
data/cards.js generated — loaded by index.html (window.RIFTBOUND_DATA)
data/prices.js generated — loaded by index.html (window.RIFTBOUND_PRICES)
data/*.json generated — same payloads, for other tooling (git-ignored)
The .json twins aren't committed; the sync scripts produce them. Prices are
optional — with no data/prices.js the app simply omits every price.
deck.js deliberately touches no DOM, so the rules engine can be exercised
straight from Node.
cloud.js and social.js are strictly additive, and they say so by construction:
both return early when supabase-config.js still holds placeholders, removing the
buttons they own on the way out. They reach app.js only through the small object
it publishes as window.RiftboundApp — read the collection, replace it, sanitise a
blob, raise a toast, enter or leave visiting mode. Delete both files and the config
and the site is the local-only tracker it started as.
Riftcodex community API (no auth, no key). Unofficial
fan project, not affiliated with Riot Games. Card art is served from Riot's own
CDN, which is Sanity-backed — appending w=320&fm=webp turns a 721 KB PNG into
a 19 KB thumbnail, which is what the grid loads.
Currently 1320 cards / 1171 collectable + 149 promos, across:
| Set | Name | Cards |
|---|---|---|
| VEN | Vendetta | 227 |
| UNL | Unleashed | 280 |
| SFD | Spiritforged | 288 |
| OGN | Origins | 352 |
| OGS | Origins: Proving Grounds | 24 |
| OPP / PR / JDG | promo sets | 149 |
Riftcodex was chosen over RiftScribe because RiftScribe is missing Vendetta and the promo sets (950 cards vs 1320), has no multi-domain data, and guesses at set names. Both use the same card IDs.
Other options: Scrydex (has pricing,
$29/mo minimum), Piltover Archive,
RiftStorm. Only sync-cards.mjs would need to change —
the app just reads window.RIFTBOUND_DATA.
TCGplayer market prices, in USD, matched on the tcgplayer_id each card already
carries — so no fuzzy name matching and no mis-priced alternate arts. One paged
request per 50 products covers the whole game in about 30 calls.
96 Vendetta cards have no Riftcodex TCGplayer ID yet, so those fall back to a set + name match, accepted only when it resolves to exactly one product. Checked against the 1189 ID-matched cards, the name match agrees on all 1189 and contradicts none. Coverage is 1304 of 1320 cards; the remaining 16 are Metal promos with no sales history and 5 Vendetta cards not yet listed.
Cardmarket was the first choice and isn't usable. Their official API
stopped accepting applications,
the site itself is behind Cloudflare, and every remaining route is a paid
third-party mirror. If you ever want true EU market figures, the fix is confined
to sync-prices.mjs — the app only reads window.RIFTBOUND_PRICES.
sync-prices.mjs also pulls the ECB reference rate from
Frankfurter and stores meta.rates alongside the
prices, so the page can show €/£ without calling out to anything at runtime —
it stays static, works offline, and everyone reading the same sync sees the same
figure. If the rate call fails the previous sync's rate is carried forward, and
if there has never been one the app simply offers dollars only.
Note what the conversion is and isn't: it's a US market price expressed in euros, not a European market price. Cardmarket and TCGplayer genuinely differ per card, so a converted figure is the right number for "what is my collection worth" and the wrong one for "what will this cost me on Cardmarket". The picker's tooltip and the breakdown panel both name the rate and its date.
Optional, and off until configured. With no Supabase project wired up the site
is exactly what it was: localStorage, no network, no account button — cloud.js
removes the button and returns before fetching anything. Signing in adds a copy
of the collection in Postgres on top of the local one; it never replaces the
local-first behaviour.
supabase-schema.sql, Run. That creates the
collections, profiles and decks tables, their Row Level Security
policies and the public_collectors view. Every statement guards itself, so
re-run the whole file after pulling — that's how an existing project picks up
public profiles, and how it picks up saved decks.http://localhost:* if you open it locally.supabase-config.js, and commit. Depending on how new the project is, that
key is shown either as anon public (a JWT starting eyJ) or as
Publishable key (starting sb_publishable_) under Settings → API
Keys; the older anon key lives under that page's Legacy API Keys tab.
The two are interchangeable here — same privileges, same RLS behaviour.It's meant to be public. It identifies the project, not a person, and grants
nothing by itself — every request still carries the signed-in user's token, and
the policies in supabase-schema.sql make Postgres filter by auth.uid() before
returning a row. The keys to never commit are service_role and sb_secret_…,
which bypass RLS entirely.
The first time a device signs in to an account it merges: local and cloud are two histories that both deserve to survive, so each card takes the larger quantity and either side's wishlist flag. That's what makes "I've been tracking on my laptop, now I'm signing in on my phone" do the obvious thing.
After that the cloud is authoritative. A device that sees a newer updated_at
than the one it last applied replaces its local copy wholesale. Merging on
every sync would look safer and be worse — it can never represent a removal, so
every card you ever sold would come back. The cost of the wholesale rule is that
two devices editing simultaneously resolve last-write-wins, which is the right
trade for a collection tracker.
The account button carries a dot: green synced, gold saving, amber offline. An
offline write isn't lost — it's in localStorage like always, and the next
successful push carries it up.
Nothing, unless you use it. vendor/supabase.js (the client, vendored so the
site keeps its no-build-step, no-runtime-CDN shape) is injected on demand — only
when a stored session needs restoring or someone opens the sign-in dialog. A
visitor who never signs in fetches exactly what they always did.
Off by default, and off is genuinely off: until you turn the switch on, no other account can see that yours exists.
Turning it on. Open your account (the button with your name on it) and flip
Public profile. It asks for a handle first — 3–20 characters, letters, digits,
- and _. That handle is the only thing about you other players ever see; your
email stays in auth.users and is never read by anything here. Turning the switch
back off is one click and takes effect immediately.
Who can look. Signed-in accounts only. A public collection is not on the open
web — an anonymous request gets nothing, because every policy tests
auth.uid() is not null before it tests anything else. The profile page hands you
a ?u=yourhandle link to share, and opening it signed out prompts to sign in
rather than showing the collection.
Browsing. The Collectors button next to the search box lists everyone public, biggest collection first. Pick one and their cards replace yours in the grid.
The visiting view is the same grid, not a second one. Their collection is swapped in behind the four accessors every read already goes through, so the search box, all six filters, every sort order, the set-progress panel and the value breakdown are pointed at their cards for free — search "yordle" while visiting and you're searching their binder. That's the whole reason it works this way: a separate viewer would have had to grow its own copy of all of it, and the copies would have drifted.
A bar across the top carries the comparison — their unique cards, copies, foils, wishlist and worth, each with yours underneath as a ratio. Percentages up to a doubling, multiples past it, because "3,410% of yours" is a figure you have to decode and "34× yours" is one you can see.
Everything is read-only while the bar is up. The steppers become plain counts,
the wishlist star becomes a mark rather than a button, and setEntry refuses
outright — the guard is at the write, not spread across the four controls that
reach it. Open a card and you get both sides at once: what they have and what you
have. Build deck, Open a pack, Export and Import all leave the header while
visiting; each of them means your collection, and leaving them up over someone
else's cards would misstate whose they are.
Three additions to supabase-schema.sql, all additive:
profiles — one row per account: handle, an is_public flag, nothing else.
Handles are unique case-insensitively, via a unique index on lower(handle), so
Karra and karra can't be two people.collections. Permissive policies OR together, so
this widens reads without touching "read own collection", and the write policies
are untouched — which is exactly what makes a public collection readable by
everyone and writable by nobody but its owner. The public test lives in a
security definer function so the policy doesn't depend on another table's
policies.public_collectors — a security_invoker view (Postgres 15+, which every
current Supabase project is) joining the two and counting the cards, so the
browse list is one small query instead of downloading every public collection to
measure it. security_invoker is what keeps both tables' RLS applying through
the view; without it a view runs as its owner and hands out rows RLS meant to
withhold.Nothing arriving from another account is trusted. A fetched collection goes
through the same sanitize() the file importer uses — unknown card ids dropped,
every count clamped — before it reaches the grid.
Foil copies are counted separately from ordinary ones, because they price
separately — often several times the normal card. A collection entry is
{ q, f, w }: q ordinary copies, f foils, w the wishlist flag. Entries
saved before foils existed have no f, which reads as zero, so nothing needed
migrating.
Each tile gets a second, quieter stepper under the main one. The count badge shows every copy you own and a small iridescent badge underneath says how many of those are foil, rather than putting two competing numbers side by side. The price row shows the foil market price beside the normal one whenever TCGplayer has both, so the gap is visible before you own either.
Everything downstream follows the split:
foil column expects.Standard Qty and Foil Qty — the same shape q and f already are, and the one every
other Riftbound importer reads. See
Exporting to other trackers.5x Chaos Rune (UNL R05) [3 foil] — since that list is meant to read like a binder.sync-prices.mjs reads foil prices from a per-product endpoint, the only one
that separates Normal from Foil. There is no batch form, so it costs one request
per card — about 1,380 of them, run six at a time inside the same daily job. A
lookup that fails carries the previous sync's foil price forward rather than
dropping it, the same way the exchange rate is.
Riftcodex lags TCGplayer's catalogue. Most visibly it carries no Rune rows at all for Unleashed or Spiritforged, so a rune pulled from either pack had nowhere to go; 232 of TCGplayer's products had no counterpart here in total.
sync-cards.mjs keeps Riftcodex as the source of record and fills what it
lacks, rebuilding the card ID from the collector number. Collections are keyed
by ID, so a wrong guess is worse than an absent card — a synthetic ID colliding
with a real one silently rewrites what someone owns. Three guards apply:
131/219 or R05, nothing elseThe predictor was checked by replaying it over the 1,049 products already held: every one reproduces its real ID. On the current catalogue the guards admit 91 of the 232, including all 54 missing runes. The 141 left out are Prize Wall metals whose IDs would have collided, tokens, and starred signature variants.
Synthesised cards carry type, domains, rarity, costs and often rules text.
TCGplayer's CDN refuses hotlinks, so art comes from a third pass instead: dotgg,
the catalogue behind riftbound.gg and the same one the
riftbound.gg export already targets, publishes card images on an open CDN. Their
ID scheme differs on variants and matching the wrong row would hang another
printing's art on a card, so the lookup tries the specific forms first —
OGN-166a for an alternate art, UNL-229-STAR for a signature — before the
plain number. That illustrates 42 of the 91; the rest are Organized Play promos
and Oversized cards dotgg files differently, and they keep a labelled
placeholder that dims and brightens on ownership like a real image. Costs follow
Riftcodex's convention rather than TCGplayer's zeros, or a Rune claiming energy 0
would sort ahead of the cards the cost sorts deliberately sink. They're flagged
partial: true, and a real Riftcodex row takes over whenever one appears.
TCGplayer sells in US dollars and Cardmarket in euros, and the same card genuinely differs between them — often far past any exchange rate. Ahri, Nine-Tailed Fox (Signature) is $3,420 on TCGplayer and €1,350 on Cardmarket; converting the dollar figure at the ECB rate would claim €2,957, overstating it by about €1,600.
So the currency picker changes market, not just units:
| Choice | Source |
|---|---|
| USD | TCGplayer, as sold |
| EUR | Cardmarket's own European prices, not converted |
| GBP | TCGplayer, converted at the stored ECB rate |
Cardmarket figures come from dotgg, which publishes them beside its own catalogue. That closes the gap the price section below describes: with real European prices, EUR is now the right number for "what will this cost me on Cardmarket", where before it was only ever a US price wearing a euro sign.
Coverage isn't total — about 1,190 of our cards carry a Cardmarket price and 1,150 a Cardmarket foil price. Anything Cardmarket doesn't list falls back to a converted TCGplayer figure, and the worth panel says how many copies that applies to rather than blending the two silently. The count is per printing, because Cardmarket often lists a card's normal copy and not its foil.
The consequence worth knowing: collection worth in EUR and in USD will not reconcile at any exchange rate, because they are answers to different questions. That's the point of it.