Privacy

The short version: every drill on the site works as a guest, and if you play that way, your progress never leaves your browser. Signing in is optional and stores your progress under your account. Three things you type can end up on a server, and only if you choose to put them there: the email address you sign in with, the profile text you fill in (display name, username, bio), and a feedback message if you send one. A live multiplayer table is the other case where a guest touches a server. All of it is spelled out below — the actual keys, the actual rows and the actual analytics events, including the parts that are inconvenient to admit.

What is stored on your device

The app is guest-first. Everything it needs lives in your browser's local storage under these keys:

  • card-counting.v3 — your whole save: table settings (decks, dealer rules, deal speed, counting system, sound, haptics, reduced motion), per-game XP and skill tallies, recent session records, day streak and best streak, achievements, the coin balance and its ledger of earnings and cosmetic purchases, the daily chest and missions, equipped cosmetics, and your profile fields (display name, username, avatar icon and colour, bio).
  • card-counting.v1 — the storage key an older version of the app used. It is read once, if the current key has no save yet, and is never written to again.
  • card-counting.onboarded and card-counting.entryChosen — two flags recording that you have seen the intro and picked how to enter.
  • card-counting.feedbackAsked, card-counting.feedbackAsks and card-counting.feedbackSent— the feedback prompt's memory of itself on this device: the date it first asked you, how many times it has asked, and whether anything was ever sent from here. They exist so that it stops asking. They hold no message text, and they stay on this device — they are never synced to an account, so a prompt shown on your laptop is not a prompt shown on your phone.
  • cc.notify.<game>— set only if you tap “notify me” on a game that isn't built yet. It is a local flag; it does not sign you up for anything, because there is nothing to sign up to.
  • cc.live.room — in session storage, not local storage: the six-character code of the last live table you created or joined, so a refresh can put you back. It disappears when you close the tab.

Clearing site data for this domain in your browser erases all of it, permanently, and the app starts fresh. As a guest, nothing in that list is transmitted anywhere.

What is stored on the server, and when

Progress is only synced to a server if you sign in. Sign-in is a one-time code sent to your email address — there is no password to store. That email address is held by the authentication provider (Supabase) so it can recognise you next time, and it is the only contact detail the site has for you. It is not, however, the only thing you type that gets stored: the profile text you fill in and any feedback message you send are user-written too, and both are in the list below.

When you are signed in, the app writes three kinds of row — and, if you sit at a live table, the two more described further down, and nothing else:

  • One row in a profiles table — display name, username, avatar icon and colour, bio, total XP, level, coins, current and longest streak, last-played date, preferred and unlocked counting systems, plus a copy of the same save blob described above and a separate ledger field holding the coin state.
  • One row per achievement you unlock — its key and the time it unlocked.
  • One row per feedback message you send — written only when you open the feedback box and press send. The row holds your account id, the message itself (up to 1,000 characters of whatever you chose to type), whether you ticked the box saying you are happy to be replied to, and a short machine-written note of where you sent it from: the in-site route, plus the game and mode if you were in one. Nothing else you typed goes into that note. The table exists because the app has no contact form and no mailto address, so this is the only way to reach the person who builds it.

Who can read a feedback message: nobody, through the app. That table has Row Level Security on with an insert policy and nothing else — no select, no update, no delete — so it cannot be listed or read back through the API by any account, including the one that wrote the row. The only way to read it is the Supabase dashboard, which the site owner signs into and which runs with a role that bypasses those policies. The insert policy also refuses the anonymous sessions used for multiplayer, so a guest cannot write one at all.

Row Level Security is switched on for every table, but the policies are not all the same shape. The rows that are yours alone — your profile row and your achievement rows — are owner-scoped: readable and writable only by the account whose id matches them, so no other player can read your profile row. Two things are deliberately scoped differently. Feedback is scoped tighter, as just described. The multiplayer room and seat rows are scoped looser, because a shared table cannot work otherwise: they are visible to the other players at your table, and a table's host may delete another player's seat row, which is how a kick works. The next section spells out exactly what is in them.

Two honest footnotes. First, the database schema also defines tables for sessions, individual drill results and daily challenges — the app does not currently write to them, so no hand-by-hand record of your play exists server-side. The tables it does write are the three above, plus the room and seat rows in the next section if you sit at a live table. Second, those profile columns are client-authoritative: your own browser decides what XP, coins, level and profile text to put in your row, and nothing revalidates them server-side. Row Level Security stops anyone else writing your row; it is not a claim that the numbers in it were audited.

Live multiplayer tables

Joining a live table is the one case where a guest touches the server. A table seat needs an account id for the security rules to work, so the app creates an anonymous session for you — an account with no email address attached. It then writes a room row (table code, game, host, status, visibility, seat count) and a seat row carrying the identity you would show anyone at the table: display name, username, avatar and level. Players at your table can see that seat row; for a public quick-match table it is also visible to other signed-in players while the table is open and under two hours old. Nothing else about you is shared, and your local progress is not synced from an anonymous session.

What Google Analytics receives

The site uses Google Analytics 4, and only in production — the tag is not injected at all during local development or automated test runs, so those never touch the analytics property. Two separate things reach it, and only one of them is decided by this code.

The first is whatever GA4 Enhanced Measurementcollects on its own. Pageviews always; and depending on settings that live in the Google Analytics admin console rather than in this repository, that feature can also send scroll depth, clicks on outbound links, file downloads and site searches without a line of code here asking it to. GA4 also derives a coarse location from your IP address and records device and browser details on every hit. That is how GA4 works on every site that uses it, this site does not switch it off, and it would be dishonest to describe the code's event list as if it were the whole picture.

The second is a hand-written list of named events. Most of them pass through one pure mapping function, which is where the cosmetic firehose — every card flip, chip sound, ambience beat and multiplayer presence update — is explicitly dropped. A handful are built at the place they happen and go straight to the tracking helper without touching that mapper: the first-run funnel steps, the CTA clicks, the feedback box's own three events, and the two events the live blackjack table sends. So the mapper is the main filter, not the only one. Either way, this is the complete list of named events:

  • Practice quality — drill_answer (game, correct as 0 or 1), session_complete (game, mode, accuracy, reps, how it ended), hand_won (which kind of win), combo_tier (which tier).
  • Progression — level_up (the new level), unlock_achievement (its key), streak_day (days), daily_chest (day, coins), mission_complete (game, mission id, whether it was the headline mission), loyalty_grant (tier, coins), and spend_virtual_currency when you buy a cosmetic. That last one carries a coin count and the item name, with no currency attached, because the coins are practice currency and not money.
  • Navigation and first run— cta_click (which button, plus the href of the link it sits in, with any query string stripped), enter_mode (the in-site path it is taking you to, plus a 0/1 flag for whether the first-run gate stopped you), entry_gate (which step of that gate, and on two of its three steps the same flag), login (the method name “email”, and the same flag), tutorial_begin, and tutorial_complete or tutorial_skip carrying the step number you were on.
  • Feedback— feedback_prompt_shown (game, mode), feedback_dismissed (where the box was opened from), feedback_submitted (where from, whether you ticked the reply box, and the length of your message). The message text itself is never sent to Google Analytics.

No user id, no email address and no profile field is ever sent to Google Analytics. That is a checkable claim rather than a promise: every event above is assembled from the values named beside it and handed to one small tracking helper, and neither that helper nor the mapper behind it has any access to your account or your profile.

Cookies, and the banner that is not here

There is no cookie consent banner on this site, and no consent is collected. In production, Google Analytics sets its own cookies — the _ga pair — to recognise a returning browser, and it does so as soon as the page loads. If you would rather it did not, block it the way you would block any tracker: an ad blocker, a browser tracking-protection setting, or clearing site data. The app is built so that when the analytics script is absent every event is a silent no-op, so blocking it breaks nothing.

The other cookie is a session cookie set by the authentication provider when you sign in, so you stay signed in. There is no advertising cookie, because there is no advertising.

Third parties

There are three, and no others:

  • Google Analytics — the traffic measurement described above.
  • Supabase — authentication, the profile, achievement and feedback rows, and the realtime channel behind live tables.
  • Vercel — the hosting that serves these pages.

No advertising network, no affiliate tracker, no session-replay tool, no heatmap, no chat widget, no third-party fonts loaded at runtime. The content security policy the site ships enforces that: scripts may only come from this domain and Google's tag host, and connections may only go to this domain, Google Analytics and Supabase. To be precise about what that buys: a tracker added by accident as an external script would simply fail to load. The script rule does allow inline scripts, because Next.js needs that to hydrate a page, so an inline snippet would run — but it still could not send anything anywhere, since the connection rule permits only the three destinations above.

The Topics API is switched off

Every response carries a Permissions-Policy header of camera=(), microphone=(), geolocation=(), browsing-topics=(). The last entry is the unusual one: it disables Chrome's Topics API for this site. Your visits here will not be used by the browser to build advertising interest topics, and no script on these pages is allowed to read them. Most sites leave that permission at its default, which is on.

Deleting your data

  • Local data: clear site data for this domain in your browser. That removes every key listed at the top.
  • Account data:every row is tied to your account with a cascading delete, so deleting the account removes the profile row, the achievement rows, any feedback messages you sent, and any tables and seats you created — nothing is left behind. There is no in-app delete button yet; until there is, ask for deletion either through the feedback box (Settings → Send feedback, while signed in) or via the site's account on X, @thecardcounting.

Last updated 21 September 2026. If this page and the code ever disagree, the code is the thing that is true — tell me and I will fix the page.