DEV Community

尚毅段
尚毅段

Posted on Originally published at mw4camotracker.com

Modeling 33 weapons 9 classes as data (not screenshots)

Modeling 33 weapons × 9 classes as data (not screenshots)

Most camo trackers are a grid of screenshots. You scroll, you find your weapon, you count boxes with your eyes, and when the game patches something the images are wrong until someone re-uploads them.

I built MW4 Camo Tracker the other way: the whole grind is one JSON dataset, and every screen renders from it. Here is the shape of that data and the parts worth reusing.

The data is a tree with three levels

The top level is class: assault rifles (8), submachine guns (5), shotguns, light machine guns (3), marksman rifles (3), sniper rifles (3), pistols (4), launchers (4), melee. That is 33 weapons.

Each weapon owns an ordered list of camo rows. Order matters, because the game unlocks in a fixed sequence: military camos first, then the 99 special camo challenges, then gold, platinum, harmonic break, divergence, and the mastery set on top. The site exposes 473 checkable rows in total, and every row has a stable id built from weapon + camo, not an array index.

That last decision saves a lot of pain. Array indexes shift the moment a patch adds or removes a camo, and any saved progress then lands on the wrong row. A synthetic key like m4-gold survives reordering.

// rows are generated at build time from the dataset, ids are derived, never positional
const rowId = (weapon, camo) => `${weapon.slug}-${camo.slug}`;
Enter fullscreen mode Exit fullscreen mode

Same reason the dataset keeps the class list as a separate table. Nine classes is small enough to inline, but the sidebar, the filters and the sitemap all read from one array instead of repeating the list three times.

Why not screenshots

Screenshots are easy to produce and cheap to look at once, but they carry cost that grows quietly:

  • A 1400px webp per weapon is fine for one page and painful when every weapon page needs its own hero image.
  • You cannot compute anything from an image: no percentage, no "next unfinished row", no sort by challenge type.
  • Screen readers get nothing. Text rows can be tabbed, labelled and announced.
  • After a patch, every changed row means an image edit. Text means one JSON diff.

The counterargument is that screenshots show the actual game UI and users trust that. That is fair — but the trust comes from the weapon names and camo names matching the game, not from the image format. A text row that says "Gold — 0/1" carries the same fact.

Progress state lives in the browser

Every tick is stored locally and the page renders from that store. The schema is one object: dataset version plus a map of row id to timestamp.

{
  version: 1,
  dataset: "2026-11",
  rows: { "m4-gold": 1762361000000, "ak74-platinum": 1762361402000 }
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter more than they look:

  1. Dataset version is stored with progress. When the dataset changes, the app can tell whether a missing row was completed or removed. Without it, a patch silently resets partial progress.
  2. Writes are debounced and total count is rendered from the map. No separate counter to get out of sync. The header says 0 / 473 because it counted the map on render, and the map is the only source of truth.

The tradeoff is device-local state: clear your browser data and the grind is gone. That is why the site also offers a Google sign-in to sync, and an export the user can keep. Local-first with an explicit sync opt-in beats an account wall in front of a checklist, and it keeps the first visit fast.

The "next challenge" suggestion is rule-based, on purpose

The site suggests an unlock order. It is generated from the published camo order plus the rows you already ticked. It does not claim to know which challenge is easiest for you, because that depends on how you play. The copy on the page says so.

That distinction is worth writing down in the product, not just in a code comment. A tool that says "easiest" and gets it wrong loses trust on the second weapon.

What I would do differently

  • I started with a flat array per weapon and had to migrate. The synthetic id should have been there from day one.
  • I kept the class order in the component. It belongs in the dataset next to the weapons.
  • I underestimated how much of the UI is just "does this row have a timestamp". A single derived selector would have replaced three separate conditions.

None of this is novel architecture. It is the same argument as any catalog app: store facts, render views, keep ids stable, and let the data survive the next patch. The camo grid just makes the argument easy to see, because the whole product is one long checklist.

Top comments (0)