# Reproduction kit: "What a free browser game really downloads" Article: https://manxlab.com/research/free-game-trackers/ This kit lets anyone recompute, from published files and with nothing but Python 3, four statements in the article: | Statement in the article | Reproduced value | |---|---| | We classify 231 domains as advertising or tracking | 231 | | The lists ad blockers use flag 304 | 304 | | Of the 244 classified domains that we or the lists flag, both flag 190 (78%) | 190 of 244 = 77.9% (by the other formula: 216 of 270 = 80.0%) | | On ABCya, every request to Google's ad server asked for non-personalised ads, and three carried the child-directed flag | 33 of 33 requests carried `npa=1`; 3 requests carried `tfcd=1`, in 3 loads of 1 page | `RESULTS.md` sets each figure beside the article's wording. Everything this file mentions is published at the addresses below, except where it says plainly that something is not published. ## What was measured, and when On 28 September 2026, between 15:31 and 17:15 UTC, an automated Chrome (154.0.8037.58) on a US connection opened 78 game pages: ten each from ABCya, Armor Games, Coolmath Games, Friv, itch.io, Kizi and Y8, and the eight games on MANXLAB. Each page was loaded three times and left alone for 60 seconds. Nothing was clicked. Three pages have two loads instead of three, which leaves 231 loads. Every network request was recorded: 350,534 requests, of which 339,264 finished within the 60 seconds. ## Reproduce You need `python3` (3.8 or later; standard library only) and an internet connection for the first command. **1. Download these 16 files into one empty folder.** Fourteen are in this kit's folder: https://manxlab.com/research/free-game-trackers/reproduce/README.md https://manxlab.com/research/free-game-trackers/reproduce/RESULTS.md https://manxlab.com/research/free-game-trackers/reproduce/fetch_lists.py https://manxlab.com/research/free-game-trackers/reproduce/crosscheck.py https://manxlab.com/research/free-game-trackers/reproduce/count_ad_flags.py https://manxlab.com/research/free-game-trackers/reproduce/studylib.py https://manxlab.com/research/free-game-trackers/reproduce/requests-noquery.csv.gz https://manxlab.com/research/free-game-trackers/reproduce/public_suffix_list.dat https://manxlab.com/research/free-game-trackers/reproduce/crosscheck.csv https://manxlab.com/research/free-game-trackers/reproduce/summary.json https://manxlab.com/research/free-game-trackers/reproduce/ad_flags.csv https://manxlab.com/research/free-game-trackers/reproduce/ad_flag_requests.csv https://manxlab.com/research/free-game-trackers/reproduce/build_requests_table.py https://manxlab.com/research/free-game-trackers/reproduce/url_scan_report_noquery.json Two are published with the article, one folder up. Put them in the same folder as the rest: https://manxlab.com/research/free-game-trackers/entities.json https://manxlab.com/research/free-game-trackers/supplement.csv (If you copy the site's layout instead, with the kit in a folder named `reproduce` and these two files in the folder above it, that works too: the scripts look in both places.) **2. In that folder, run:** python3 fetch_lists.py # downloads the pinned filter lists into lists/ (about 9 MB, 10 s) python3 crosscheck.py # 231, 304, 190/244, 216/270 -> crosscheck.csv, summary.json python3 count_ad_flags.py # npa / tfcd per portal -> ad_flags.csv, ad_flag_requests.csv The last two take under a minute each. **3. Compare.** The two scripts print the tables shown under "Expected output" and write four files over the copies you downloaded. To confirm that what they wrote is identical to what we published, print the files' sha256 and compare with the values under "Expected output": python3 -c "import hashlib,sys; [print(hashlib.sha256(open(f,'rb').read()).hexdigest(), f) for f in sys.argv[1:]]" ad_flag_requests.csv ad_flags.csv crosscheck.csv summary.json Files the scripts read: | File | What it is | |---|---| | `requests-noquery.csv.gz` | every request of every load (see "The request table") | | `entities.json` | registrable domain -> owner and categories. Published with the article. May sit in this folder or one level up. | | `supplement.csv` | the 83 hand-sourced rows of `entities.json`, including each portal's own domains. Same places. | | `public_suffix_list.dat` | Public Suffix List, version 2026-09-24_13-26-36_UTC (commit a179a48), the copy the study used. Mozilla Public License 2.0. | | `lists/easylist.txt`, `lists/easyprivacy.txt`, `lists/PINNED.json` | created on your machine by `fetch_lists.py`; not part of this kit | ## Expected output `python3 crosscheck.py` prints `summary.json` (and saves it). Its figures, laid out for reading: third_party_domains 483 domains_we_classify_as_advertising_or_tracking 231 domains_the_lists_flag 304 (EasyList only 184, EasyPrivacy only 109, both 11) domains_we_have_a_category_for 270 domains_with_no_category 213 (of which the lists flag 101) lists flag lists do not we say advertising/tracking 190 41 = 231 we say something else 13 26 = 39 agreement_overlap_of_flagged 190 / 244 = 77.9% agreement_same_verdict 216 / 270 = 80.0% `python3 count_ad_flags.py` prints: portal pages loads ad req loads w/ad npa=1 loads all npa tfcd=1 req loads pages abcya 10 30 33 30 33 30 3 3 1 abcya-break-the-bank-counting armor 10 30 188 30 0 0 0 0 0 coolmath 10 29 96 29 0 0 0 0 0 friv 10 30 0 0 0 0 0 0 0 itch 10 30 0 0 0 0 0 0 0 kizi 10 29 0 0 0 0 0 0 0 manxlab 8 24 0 0 0 0 0 0 0 y8 10 29 43 29 0 0 0 0 0 sha256 of the four output files: e48d58a32acd681aff96d70fda2a26819b163c6e8218315a10b227e81a1a355c ad_flag_requests.csv cfd21c5d7b1154702623dbb653b0be29ea85126c94aa1d95ad23301477bb2620 ad_flags.csv af83c7d97d0563b27da1fcb78946b6b3df93a1cc0c7dc9081233d081c38f4559 crosscheck.csv 77105178af779ade2c692d82dcb1e61543968c9f6a6f7f934e036145081c4763 summary.json ## Definitions These restate what the study's own scripts do (`analyze.py`, `aggregate.py`, `facts.py`, published beside the article); `studylib.py` holds the code. - **Domain.** The registrable domain (for example `doubleclick.net` for `securepubads.g.doubleclick.net`), worked out with the Public Suffix List. - **First 60 seconds.** A request counts when it finished no later than 60.000 seconds after navigation started. `data:` and `blob:` URLs are not requests. - **Third party.** A request is third party when the owner of its domain is not the owner of the page's domain. Owners come from `entities.json`; a domain with no entry is its own owner. A portal's own delivery domains are given the portal as owner in `supplement.csv` (category `first-party-portal`), which is how they are kept out. - **Third-party domain.** A domain that was third party on at least one load. All 231 loads are pooled, MANXLAB's included. The 483 include five entries that are not internet domains: four browser-extension identifiers that pages probed for, and `about:blank`. The study's scripts count them, so this kit does. None is classified or flagged, so no other figure depends on them. - **Advertising or tracking (our label).** The domain's categories in `entities.json` include `ad`, `analytics`, `marketing` or `tag-manager`. - **We have a category for it.** The domain has an entry in `entities.json` with a category other than `unknown`. 270 of the 483 do. The other 213 are left unlabelled rather than guessed. - **The lists flag it.** From EasyList and EasyPrivacy only pure domain rules are used: lines of the exact form `||host^`, optionally followed by `$third-party` or `$3p`. Rules with a path, with any other option, and `@@` exceptions are ignored. A domain is flagged when any hostname requested under it, as a third party and within the 60 seconds, equals a rule's host or is a subdomain of one. This is narrower than what an ad blocker does: "not flagged" does not mean an ad blocker would let the request through. - **Agreement, overlap of flagged (190 / 244 = 77.9%).** Of the domains we have a category for that either we or the lists flag as advertising or tracking, the share that both flag. - **Agreement, same verdict (216 / 270 = 80.0%).** Of all the domains we have a category for, the share where we and the lists give the same verdict: both flag it, or neither does. - **Request to Google's ad server.** A request, finished within the 60 seconds, to a host under `doubleclick.net` with the path `/gampad/ads`. `npa=1` asks for non-personalised ads; `tfcd=1` tags the request for child-directed treatment. Requests to other Google endpoints (Analytics, for example) are not counted, whatever parameters they carry. ## How the lists are pinned The study recorded EasyList and EasyPrivacy at `! Version: 202609281501` (built 28 September 2026, 15:01 UTC) and noted the source repository `easylist/easylist` at commit `06e33b6b5ac0944dab90e8ca459c3f2e3f17a602`. It also recorded how many pure domain rules those files held: 45,175 in EasyList, 46,937 in EasyPrivacy, 92,082 in the union. The files themselves were not kept. The compiled files of that minute could not be obtained: easylist.to serves only the current build, and the Internet Archive's nearest captures are later ones (EasyList 22:39 UTC that day, EasyPrivacy the day after). So `fetch_lists.py` rebuilds them from source. It downloads the GitHub archive of `easylist/easylist` at one commit and expands the authors' own build templates, `easylist.template` and `easyprivacy.template`, replacing each `%include easylist:PATH%` line with the file at PATH. The included paths are: - EasyList (26 files): `template_header.txt`; in `easylist/`: `easylist_general_block.txt`, `easylist_general_block_dimensions.txt`, `easylist_general_block_popup.txt`, `easylist_general_hide.txt`, `easylist_allowlist_general_hide.txt`, `easylist_adservers.txt`, `easylist_adservers_popup.txt`, `easylist_thirdparty.txt`, `easylist_thirdparty_popup.txt`, `easylist_specific_block.txt`, `easylist_specific_block_popup.txt`, `easylist_specific_hide.txt`, `easylist_specific_hide_abp.txt`, `easylist_allowlist.txt`, `easylist_allowlist_dimensions.txt`, `easylist_allowlist_popup.txt`; in `easylist_adult/`: `adult_adservers.txt`, `adult_adservers_popup.txt`, `adult_thirdparty.txt`, `adult_thirdparty_popup.txt`, `adult_specific_block.txt`, `adult_specific_block_popup.txt`, `adult_specific_hide.txt`, `adult_allowlist.txt`, `adult_allowlist_popup.txt`. - EasyPrivacy (39 files): `template_header.txt` and every file the template names in `easyprivacy/` (all 38 files in that folder at this commit). `lists/PINNED.json`, written by the script, records the exact paths. **Which commit.** The pinned commit is `4300ac0edfef0a381cc8ca0b12c8fa965715d810`, made at 15:01:31 UTC, the minute the version number names. It is not the commit the study's notes record. That one was made at 15:15:43 UTC, fourteen minutes after the build, so the 15:01 build cannot have been made from it. The recorded rule counts settle the question: | Commit (28 Sep 2026, UTC) | EasyList rules | EasyPrivacy rules | Union | |---|---:|---:|---:| | `e056a56` 14:48:27 | 45,173 | 46,937 | 92,080 | | **`4300ac0` 15:01:31** | **45,175** | **46,937** | **92,082** | | `06e33b6` 15:15:43 (in the study's notes) | 45,178 | 46,937 | 92,085 | | Recorded for version 202609281501 | 45,175 | 46,937 | 92,082 | `fetch_lists.py` stops with an error unless the rebuilt lists have exactly the recorded counts. For EasyPrivacy the count is the same at all three commits, which is consistent with the note that both lists came from the same build. The choice does not change any result. `crosscheck.csv` is byte-identical with all three commits (the five rules that differ are throwaway ad-server domains that no page in the study contacted). To try another commit: `python3 fetch_lists.py --commit SHA`. What this cannot show: that the article's figures were computed with that version. No script or log records which list files produced the article's 304 and 78%. The pinned lists return 304 exactly, which is what one would expect if they were. ## The request table `requests-noquery.csv.gz` (3.1 MB; 47 MB unpacked; 350,534 rows) is the study's raw captures reduced to what these scripts need: | Column | Meaning | |---|---| | `page` | the game's label, for example `abcya-alphabet-puzzle` | | `portal` | the label's first word | | `load` | 1, 2 or 3 | | `page_url` | the address that was opened | | `seconds` | seconds after navigation at which the request finished | | `url` | the request address without its parameters (see below) | | `resource_type` | Chrome's resource type (image, script, document, ...) | It includes requests that finished after 60 seconds (11,270 of them); the scripts apply the window. Response sizes, cookies and frames are not in it; those are in the article's `runs.csv` and `domains.csv`. **Why addresses are published without their parameters.** Advertising servers echo the visitor's IP address, and the location they derive from it, back into request addresses. The full addresses therefore identify the connection the measurement was run from. So in this table: - every query string (everything from the `?`) is removed, with one exception: on requests to Google's ad server (`/gampad/ads` under `doubleclick.net`) the two flags this kit counts, `npa` and `tfcd`, are kept; - a path segment (the text between two `/`) is replaced by `REDACTED` where it contained an IP address or the measuring connection's address or location, in plain or encoded form (498 segments); - every path segment that contains `=` is replaced by `REDACTED` (3,384 segments), because some advertising addresses carry their parameters in the path, as `/name=value;name=value`, instead of after a `?`. Scheme, host and the rest of the path are untouched. Every figure above depends only on those and on the two flags, so removing the parameters changes no result: before publication we ran both scripts on the raw captures and on this table and the four output files were byte-identical. **What is not published,** and why: the raw captures (one JSON file per load), and a larger table that keeps the query strings. Both hold full request addresses. You do not need either to reproduce the figures. `build_requests_table.py` is the script that made the table from the raw captures, with the command shown at the top of that file. **It cannot be run with the published files**, because it needs the raw captures. It is here so that the way the table was derived and redacted is on the record; reproducing the figures does not involve it. `url_scan_report_noquery.json` is the tally it wrote when it made this table: how many addresses it changed and under which rule, and a re-scan of the finished file that found nothing left (`left_after_redaction` is empty). The "private term" rules in that tally refer to a list of the measuring connection's address and location, which is not published. If you run your own measurement with the article's `measure.cjs`, both scripts accept your folder of captures: `python3 crosscheck.py --source DIR`. Your numbers will differ from ours; ad auctions change from minute to minute. ## Licences and attribution - This kit's scripts and output files: CC BY 4.0, with a link to the article. - **EasyList and EasyPrivacy are by The EasyList authors (https://easylist.to/)**, dual licensed under the GNU General Public License version 3 or later and Creative Commons Attribution-ShareAlike 3.0 Unported or later. This kit does not contain or redistribute them. `fetch_lists.py` downloads them from the authors' repository to your machine, into `lists/`. `crosscheck.csv` names, for each domain in the study, the rule hosts that matched it; it reproduces no list. - `entities.json`: third-party-web 0.30.0 by Patrick Hulce (MIT licence; see https://manxlab.com/research/free-game-trackers/THIRD-PARTY-WEB-LICENSE.txt) plus our supplement (CC BY 4.0). - `public_suffix_list.dat`: Mozilla Public License 2.0, from https://publicsuffix.org/. The licence notice is at the top of the file. ## Files in this kit | File | What it is | |---|---| | `README.md`, `RESULTS.md` | this file; each figure beside the article's wording | | `fetch_lists.py`, `crosscheck.py`, `count_ad_flags.py` | the three commands | | `studylib.py` | shared definitions the scripts import | | `requests-noquery.csv.gz` | the request table | | `public_suffix_list.dat` | Public Suffix List | | `crosscheck.csv`, `summary.json`, `ad_flags.csv`, `ad_flag_requests.csv` | our output, for comparison with yours | | `build_requests_table.py`, `url_scan_report_noquery.json` | how the table was made (a record; cannot be run without the unpublished raw captures) |