Skip to content

Latest commit

 

History

42 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Winedle

A daily wine guessing game. Answers are grape varieties and wines — Chardonnay and Chablis, Syrah and Barolo — guessed against each other on one board. Every guess is scored across ten attributes.

Play it

Open index.html — one self-contained file, no server, no install.

Editing

index.html is generated. Never edit it; edit the sources and rebuild:

page.html        page shell (the template)
data/wines.js    the answer bank (the part worth your attention)
data/aromas.js   aroma family map
src/game.js      comparison engine, schedule, rendering
src/style.css    the label aesthetic
./build.sh       runs the tests, then inlines all of it into index.html

Run ./build.sh after any change. Inlining is not just for portability: four separate files meant four separate caches, and a browser holding a stale data/wines.js next to a fresh src/game.js runs new code against an old answer bank. That happened during development. One file cannot desynchronise with itself.

Tests

node test.js

Checks data integrity (schema, ordinal ranges, aroma shape, name and alias collisions, and the no-two-wines-alike invariant), the comparison engine including arrow direction and symmetry, schedule determinism, and name resolution. build.sh runs it first and refuses to build on failure.

Adding wines

Append to WINES in data/wines.js. Every record needs all fields; the build does not validate, so keep the shape identical to its neighbours. The scale legend lives in the file header.

Two rules the data follows, stated in the game's How to Play so players do not argue with the tiles:

  • country/region is the grape's classic home, not everywhere it is planted.
  • colorInt is judged within the wine's own colour category, WSET-style.

The bank currently holds 85 grapes — roughly twelve weeks before the deck runs out. Each cycle reshuffles, so the order is never the same twice.

Run node test.js after adding — it will catch a record that is indistinguishable from an existing one, or an alias that collides.

Colour and style cohorts

Rosé is a colour value that only wines carry — a grape's colour is its skin, and rosé is a winemaking choice, so it sits alongside Dessert and Fortified as something claimed only where it is a fact about the bottle.

The suite requires every displayable value of kind and color to have at least five members. A style with one member is not a clue, it is the answer: Off-dry shipped with exactly one wine in it and the Type tile gave the game away whenever it appeared. Off-dry was later removed entirely — see Sweetness — but the rule it prompted stayed.

Aroma families

data/aromas.js groups the 66-term vocabulary into 15 families. The aroma tile scores an exact term first, then gives partial credit for the right family with the wrong note — lime against citrus, sour cherry against cherry. Before this, the tile was blank in 62% of all guess/answer pairs; it is now blank in 34%.

Every term used in data/wines.js must appear in exactly one family, and every mapped term must be used. node test.js enforces both.

Kinship (AROMA_KIN) covers terms that are the same thing at a different shade — cherry / black cherry / sour cherry, plum / red plum, toast / bread. These sit in different families on purpose, because red fruit against black fruit is a distinction a taster makes, but scoring them as unrelated read as a bug: 5.2% of all pairs had a same-fruit term earning nothing. Kinship is checked alongside family, never instead of it, and the suite requires every kin group to actually cross a family boundary — otherwise family already covered it.

Leftover aromas are paired by maximum bipartite matching, not by claiming the first available partner. Greedy claiming let an earlier term take the only partner a later one could have used, so the score came out below what the two sets genuinely shared — and changed depending on the order the aromas happened to be written in. A test brute-forces every assignment for all 22,052 pairs and requires the optimum.

Attributes scored

Colour · Country · Region · Depth · Body · Tannin · Acidity · Climate · Aromas

Green = exact. Amber = one step off, same continent, or shared aromas. Blank = no match. Arrows on structural tiles point toward the answer.

Green beside amber is the harder pair for deuteranopes — it is the classic confusion, and worse than the oxblood it replaced. The ✓ and ≈ marks on each tile are what actually carry the state; the colour corroborates.

Modes

URL What it is
/ the daily puzzle
/?d=<n> an archived day
/?mode=practice unlimited random wines
/?w=<token> a challenge link for one specific wine

Challenge tokens are URL-safe base64 of the grape name — enough that the answer is not sitting in plain text in the address bar, not a security measure. The "Challenge a friend" button on the end panel copies one for the wine just played, from any mode.

Practice draws from the whole bank regardless of tier, leans toward wines you have previously failed (PRACTICE_MISS_BIAS), and touches neither the streak nor the shared result. Wines you miss are recorded in winedle:misses and cleared when you next get them right.

Sweetness

There is no sweetness column, deliberately. A grape has no inherent sweetness — Riesling runs from Trocken to Trockenbeerenauslese — so the tile would carry an invented value for the 87 grape records, and it measured 94% green across all pairs while separating zero pairs the other tiles could not already separate.

kind used to carry sweetness as well — Off-dry and Sweet sat alongside Still/Sparkling/Fortified — but that mixed two different axes into one tile: those three name a production method, while Off-dry and Sweet named a sugar level, and a wine can be any sweetness at any of those (a dry Sherry is still Fortified, a sweet one is too). Off-dry folded back into Still/Sparkling as ordinary table wines. Sweet became Dessert, which survives as its own kind for the same reason Fortified does — botrytis, ice wine, dried grapes and late harvest are real production methods, not just a sweetness reading. The suite requires Dessert and Fortified to keep a real cohort so the values stay guessable.

Tiers

Each wine carries tier: 1 classic, 2 known to enthusiasts, 3 specialist, split 22/35/28. The daily week runs 1,2,1,2,1,2,3 — six approachable days and one deep cut — so an 85-grape bank does not hand a first-time player Rkatsiteli. Each tier walks its own deck and does not repeat until spent.

Keeping progress

Progress lives in localStorage, which browsers may evict under storage pressure and which Safari's tracking prevention clears after seven days without interaction. On finishing a game the page requests persistent storage, which exempts it from eviction in Chrome and Firefox.

The Cellar book also offers Copy backup and Restore — a JSON blob of stats, tally and misses, for a new device or a cleared browser. Puzzle state is deliberately excluded; only the record of what you have played travels.

Numbering

The number on screen counts published puzzles, so launch day is No. 1. The internal day number is an offset from the schedule epoch and never appears in the interface; ?d= takes the public number.

Stored state

Key What it holds
winedle:state today's board
winedle:state:<n> an archived day
winedle:state:practice the current practice wine
winedle:state:w:<token> a challenge
winedle:stats played, wins, streak, guess distribution
winedle:misses wines failed, used to weight practice
winedle:met wines solved and how often — the study record

winedle:met is what the Cellar book's "Wines met" list reads. A streak says how consistent you have been; the tally says which of the bank you can actually name.

The schedule

data/schedule.js is generated data, not code: the published answer for every day from launch (day 232, 21 August 2026) onward.

node tools/schedule.js      # after adding entries, then ./build.sh

The game reads this file and never computes the answer from the bank. That matters for three reasons:

  • Everyone playing on a given day gets the same wine, whenever they loaded the page. Previously the answer was derived from the bank, so a deploy silently changed it — two friends either side of one got different wines, which makes a challenge link meaningless.
  • Adding entries cannot disturb the past or the present. Regeneration freezes every day up to and including today and rewrites only the future, so new wines join the rotation from tomorrow. Verified: adding three entries would have flipped today from Sancerre to Sangiovese under the old scheme.
  • The archive is real history, not a simulation of days nobody played.

A test fails if any bank entry never appears in the schedule, which is what forgetting to regenerate looks like.

Archive

?d=<n> replays a past puzzle, clamped to [LAUNCH_DAY, today] — so it starts at two days and grows by one a day. Archived plays are stored under their own key (winedle:state:<n>), so a replay never overwrites the live puzzle and never moves the streak.

Sharing

Ten blocks a row is honest and unreadable — it wraps in every messaging app, and six rows of it is a wall. Each row is compressed to BAR_WIDTH (5) blocks holding the same proportions of exact / close / neither, so the shape of closing in survives at a width that fits a phone. The suite checks every guess/answer pair compresses to exactly five and that a row with any exact match never rounds down to none.

The challenge text carries the score but never the wine — the recipient is meant to play it blind:

Winedle challenge — I got it in 3 tries. Can you do better?

🟥🟥🟥🟨⬜
🟥🟥🟨🟨⬜
🟥🟥🟥🟥🟥

Your turn:
https://leemoose.github.io/winedle/?w=…

Analytics and the anonymous counter

Both live in src/config.js and are off until filled in. While the values are empty nothing leaves the browser, and if either is configured but unreachable the game is unaffected — a failed counter simply omits its line.

CANONICAL_URL where the game lives          -> used in shared links off-host
GOATCOUNTER   site code from goatcounter.com  -> page views  [set: leemoose]
COUNTER_URL   deployed worker/ URL            -> "1,247 players today"

Neither fires for archive replays, practice rounds or challenge links; counting those would make the numbers meaningless. Neither fires from a loopback host or file:// either — a local build hits the same production endpoints as the live site, and an afternoon of testing can outnumber the real players. Shared links fall back to CANONICAL_URL off-host, so a local build never hands anyone a localhost address.

Page views

Sign up free at https://www.goatcounter.com/signup, take the code from your xxx.goatcounter.com address, put it in GOATCOUNTER, rebuild. No cookies, no personal data, no consent banner needed.

Counter

Deploy worker/ (see worker/README.md), then put the URL it prints into COUNTER_URL and rebuild.

It stores one record per day — a play count, a win count and a six-bucket guess distribution — and nothing else. No identifiers, no IP addresses, not even which wine it was. Records expire after 60 days. Each browser posts once per finished puzzle and reads thereafter.

It is not a leaderboard, on purpose. The game is entirely client-side, so any score can be forged with one fetch and a ranking would be fiction. Aggregates degrade gracefully under that: forging them gains nobody anything.

Installing

manifest.webmanifest plus sw.js make it installable and playable offline. The service worker is deliberately network-first: cache-first would be faster but would pin players to a stale answer bank, and a wrong wine is a worse failure than a slow load. Its fetch() call passes {cache: 'no-store'} — without it, GitHub Pages' Cache-Control: max-age=600 means the browser's own HTTP cache can silently satisfy the request with no network round trip at all, for up to ten minutes after every deploy, and the stale response gets re-committed into the offline cache on the way past. Caught live, not by inspection: right after a deploy, the page the worker served was missing code a bypassed fetch to the same URL already had. Bump CACHE in sw.js when you want old caches cleared — already done once to purge what this bug had poisoned.

Icons are captured from assets/icon-card.html the same way as the social card, then

magick <capture> -crop 840x840+0+0 +repage -resize 512x512 assets/icon-512.png

Social card

assets/og.png is a capture of assets/og-card.html, which is viewport-sized so it can be re-shot at any resolution. To regenerate: serve the folder, open the card at a viewport with a 1.905 aspect ratio, screenshot, then

magick <capture> -resize '1200x630!' -strip assets/og.png

Hints

A guess scoring HINT_AT (6) or more exact tiles is close enough that the grid stops being informative. Those rows reveal one aroma the answer carries and the guess did not — never the same one twice, falling back to oak treatment once the aromas are spent. Hints are derived at render time from the guess list, so they survive a reload and cost nothing to store.

Raising HINT_AT in src/game.js makes them rarer; at 7 the hint can only fire on 20% of answers, at 6 on 69%.

About

Winedle — the daily wine puzzle. Guess the grape variety in six tries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages