Is Bill Gates worth more than Larry Page? Is Apple bigger than Saudi Aramco? How much is a pile of Thai baht you saw for five seconds?
I kept asking myself questions like these, and in August I started building MoneyGames: games about money, not games for money. It is four small daily puzzles that run in the browser and in an Android app:
- More/Less – two cards, tap whichever is worth more. Five levels: billionaires, market caps, athlete salaries, box office, and GDP.
- Money Guessr – a pile of real banknotes and coins flashes for five seconds. Type the total.
- Moneyle – a frosted banknote and six guesses. Each miss tells you whether your currency is stronger or weaker than the hidden one.
- MoneyConnections – a 4×4 grid of flags, coins, GDP figures, and currency names. Find the four tiles that belong to the same country.
Everyone gets the same puzzles each day. This post covers how it is built: where the data comes from, how a "daily puzzle" actually works, the small UI details that stop the game from flickering, and how one backend serves both a website and a native app.
The stack
The whole thing is deliberately small:
| Layer | Choice |
|---|---|
| Web | React 19 + TypeScript + Vite, React Router SPA |
| API | One Cloudflare Worker (worker/index.ts) |
| Database | Cloudflare D1 (SQLite) |
| Static files | Workers static assets on the same Worker |
| App | Expo + Expo Router (Android), built with EAS |
One Worker serves the SPA, the JSON API, the crawler HTML, the sitemap, and runs two cron triggers. There is no separate server, no Redis, and no container. For a side project that is the main feature: deploying is npm run deploy.
1. Data: turning public lists into game cards
Every More/Less card is just a name, a raw USD value, a preformatted display value such as $834B, and an image, grouped by theme. Adding a new theme means adding data, not code.
Two kinds of data
Some lists barely change, and some change every day.
Slow data (athlete salaries, all-time box office) goes through Node scripts in scripts/. Each script turns a raw source into a clean JSON file in data/, and build-seed.mjs turns those files into db/seed.sql. The scripts have no dependencies beyond Node, and the raw downloads are kept in data/raw/ so the pipeline can be rerun offline.
Live data (Forbes real-time billionaires, World Bank GDP, top companies by market cap) is refreshed inside the Worker right before the day's More/Less puzzle is generated. Three rules made this reliable:
export async function refreshLiveStats(db: D1Database): Promise<RefreshResult> {
const [billionaires, gdp, marketCap] = await Promise.allSettled([
fetchBillionaires(),
fetchGdp(),
fetchMarketCap(),
]);
// Each source is synced in its own try/catch, so one bad source
// never blocks the other two.
...
}
-
Isolate failures.
Promise.allSettledplus onetry/catchper source. If Forbes is down, GDP and market caps still update, and yesterday's billionaire list is still a perfectly good list. -
Refuse suspicious data.
syncItemsthrows if a source returns fewer than 50 rows. A half-loaded page should never shrink the game to ten cards. -
Never overwrite curated images. Rows are matched by name (or by ticker for companies). If a row already has a local image, the incoming remote URL is ignored. Items that drop out of the top 100 are marked
is_active = 0instead of being deleted, so old archive puzzles still make sense.
Images are the real payload
The JSON is tiny; the images are not. The site ships about 700 WebP files: portraits, logos, movie posters, flags, and scans of banknotes and coins from 22 currencies.
scripts/convert-images-webp.mjs caps each image at the largest size it can ever be shown at, based on where it is used:
const MAX_SIDE = {
flags: 320, // rendered at 64px in the How-to-play cards
logos: 512, // .card-media.logo caps at 220 CSS px
card: 900, // More/Less card art
bill: 800, // .guessr-stack.bill caps at 240 CSS px
coin: 360, // coinPx() tops out at 90 CSS px
};
Currency scans come in at print resolution and never render wider than a couple of hundred CSS pixels, so this alone removed most of the weight. Two details in that script saved me from subtle bugs:
- It refuses to lose transparency. Coins are cut out on a transparent background. After encoding, the script checks whether a source with real transparency produced a WebP without alpha, and aborts if so.
- It rewrites paths from what is on disk, not from what the current run converted, so an interrupted run can simply be repeated.
Coin photos are cut out with a short NumPy script: estimate the background from the corners, find the coin radius from the 99th percentile of foreground pixels, then apply a smoothstep alpha edge so the coin does not look pasted on.
2. "Same puzzle for everyone" without storing a schedule
A daily game needs three properties: everyone gets the same puzzle, it changes at a predictable time, and old days can be replayed.
Pick one clock
Puzzles reset at midnight in America/Denver. The date is computed with Intl and nothing else:
export function denverDate(at: Date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Denver",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(at); // "2026-10-24"
}
The en-CA locale gives YYYY-MM-DD, which sorts as a string, so validating an archive date is just raw <= today && raw >= epoch. Two cron triggers (06:00 and 07:00 UTC) cover Denver midnight in both daylight and standard time.
Seed the randomness with the date
Each game gets its own deterministic random generator, seeded from a string such as 2026-10-24:moreless:billionaires:
/** Deterministic 32-bit hash (FNV-1a). */
export function hashString(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/** Mulberry32, returns [0, 1). */
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const rngFor = (key: string) => mulberry32(hashString(key));
Seeding per level matters: adding a sixth More/Less level later does not reshuffle the other five.
Then freeze it
A seed alone is not enough, because the input data changes. Tomorrow's Forbes list will differ from today's, so regenerating last Tuesday's puzzle would give a different answer. The first request of the day generates the puzzle and stores the result:
async function ensureGame<T>(db, game, date, gen: () => Promise<T>): Promise<T> {
const existing = await loadRow(db, game, date);
if (existing) return JSON.parse(existing) as T;
const payload = await gen();
await saveRow(db, game, date, payload); // INSERT OR IGNORE
const saved = await loadRow(db, game, date);
return saved ? (JSON.parse(saved) as T) : payload;
}
INSERT OR IGNORE followed by a re-read handles the race where two requests generate the same day at once: whichever insert wins, both requests return the stored row. Live data is only refreshed when generating today, so opening an archive date never rewrites the current item table.
Make the puzzle fair, not just random
A random pair can be a bad question. buildChain skips any item that clashes with the previous one, meaning the same item, the same value, or the same display value:
function clashes(a: MorelessItem, b: MorelessItem): boolean {
return a.id === b.id || a.value === b.value || a.displayValue === b.displayValue;
}
The last check is the subtle one. Two billionaires worth $5.61B and $5.65B are different numbers, but both render as $5.6B, and the player sees a coin flip.
Moneyle has a different fairness problem: the answer must not reach the browser. The puzzle endpoint returns only the blurred banknote image and the list of guessable currencies. Each guess goes to /api/moneyle/guess, which compares exchange rates on the server and reveals the target only when you win or run out of guesses.
3. Anti-flicker: the details that make it feel solid
Most of the polish work was not features. It was removing tiny flashes, jumps, and wrong frames. These were the ones players noticed.
Restore before you render
The first version loaded the day's puzzle, rendered the board, and then restored saved progress. If you had already finished, you saw the playable board for a split second before the results screen replaced it.
The fix is to restore first and only then commit the puzzle to state:
fetchMorelessToday(requestedDate).then(async (res) => {
if (cancelled) return;
await restore(res.date, res.levels); // answers, level, round, finished
if (cancelled) return;
setLevels(res.levels); // the board renders only now
setPuzzleDate(res.date);
});
Progress is stored as an answer log, and the position in the run is derived from it (level = floor(answers / 5)). Saving both would let them drift apart if the app is killed mid-animation.
Never show the previous card's picture
On phones, the right-hand More/Less card sometimes kept showing the previous question's image for up to a second or two. The images were already small; the problem was timing:
- React reused the same image component and only changed its URL. Chrome's
<img>andexpo-imageboth keep the old picture on screen until the new one has loaded. - The next card's image only started downloading when it became the current card.
The fix has two halves. Tie the image to the item so it can never be reused for a different one:
// Web
<img key={item.id} src={item.imageUrl} alt={item.name} loading="eager" />
// Expo
<Image
source={{ uri: item.imageUrl }}
recyclingKey={String(item.id)}
cachePolicy="memory-disk"
contentFit={fit}
/>
Then prefetch the whole day as soon as the puzzle arrives. That is about 30 small images:
function preloadMorelessImages(levels: MorelessLevel[]): void {
const urls = new Set<string>();
for (const lvl of levels) {
for (const item of lvl.chain) {
if (item.imageUrl) urls.add(item.imageUrl);
}
}
void Image.prefetch([...urls], "memory-disk"); // expo-image
}
On the web the same loop creates new window.Image() objects so the files land in the browser cache.
Do not start a timer the player cannot use
Money Guessr gives you five seconds to look at a pile of cash. If the images are still loading, those five seconds are wasted. The round therefore waits for that round's images, with a cap so a slow network cannot hang the game:
function waitForImages(urls: string[], timeoutMs: number): Promise<void> {
return Promise.race([
Promise.all(urls.map(loadImage)).then(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
void waitForImages(uniqueImageUrls([puzzles[round] ?? []]), 2500).then(startClock);
loadImage also awaits img.decode(), so the picture is ready to paint, not just downloaded. The countdown itself uses requestAnimationFrame and performance.now() with a setTimeout fallback, so a dropped frame does not stretch the five seconds.
Reserve space before content arrives
The rest is plain layout hygiene:
- Fixed
min-heighton the Guessr table and result areas, andaspect-ratio: 1on Connections tiles, so the layout does not jump when images load. -
font-display: swapon self-hosted, subset WOFF2 fonts. -
min-height: 100dvhwith a100vhfallback, so the mobile URL bar collapsing does not resize the board mid-game.
4. One backend, two front ends
The Android app arrived about a month after the site. The goal was simple: the same puzzles, the same scores, and the same account on both.
Same API, separate UI
The Expo app talks to the same Worker at https://moneygames.cc. The UI is written separately in React Native, because a card grid in CSS and a card grid in Flexbox are different enough that sharing components would have cost more than it saved. The rules are shared by contract: scoring constants, share text, and puzzle numbering are mirrored in mobile/src/lib/config.ts, and the server recomputes scores itself in worker/progress.ts. Relative asset paths from the API are resolved to absolute URLs on the app side:
export function assetUrl(path: string | null | undefined): string | null {
if (!path) return null;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
return `${API_ORIGIN}${path.startsWith("/") ? path : `/${path}`}`;
}
Duplicating a hundred lines of logic is a trade-off. If I add a third client, those rules move into a shared package.
Sign-in that works in a browser and in an app
The web uses an HTTP-only session cookie. The app cannot share a browser cookie jar, so it uses a bearer token, and the Worker accepts either:
return bearerToken(request) || getCookie(request, SESSION_COOKIE);
Google sign-in in the app opens the system browser with WebBrowser.openAuthSessionAsync. After the OAuth callback, the Worker does not put the session token in the redirect URL. It stores a short-lived, hashed one-time code, redirects to moneygames:///auth?code=..., and the app exchanges that code for a session with a POST /api/auth/exchange. The code is deleted on use, and redirect targets are checked against an allowlist so the flow cannot be pointed at another app.
Progress sync and conflicts
Guests keep progress in localStorage, or AsyncStorage in the app. Signed-in players also write to /api/progress. The interesting case is playing on two devices: which copy wins?
Timestamps alone are wrong. A phone with a stale clock, or an old tab saving late, could overwrite a finished run with a half-finished one. So the client compares how far along each copy is first, and only falls back to time when they are equal:
const localRank = progressRank(game, local.state); // e.g. answers.length
const remoteRank = progressRank(game, remote.state);
if (remoteRank !== localRank) {
return remoteRank > localRank ? remote : local;
}
return remote.updatedAt >= local.updatedAt ? remote : local;
The server adds a second guard in the upsert itself, so an older write can never replace a newer one:
INSERT INTO puzzle_progress (user_id, game, puzzle_date, state_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, game, puzzle_date) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
WHERE excluded.updated_at >= puzzle_progress.updated_at
When a guest signs in, everything in local storage is pushed up, so the streak they built before registering is not lost.
Shipping the app
The app is built with EAS. appVersionSource: "remote" plus autoIncrement: true lets EAS own the Android versionCode, so I only bump the human-facing version in app.json. The website deploys in about a minute, and the app takes a store review. That gap is one more reason to keep game rules on the server: a scoring fix on the Worker reaches app users immediately.
5. Bonus: an SPA and Google do not get along by default
Two Search Console problems are worth sharing, because both come from the SPA setup.
Soft 404s. Every game route returned the same empty index.html, so Google saw identical, contentless pages and flagged them as soft 404s. The fix: add the game routes to run_worker_first in wrangler.jsonc, and have the Worker inject a real title, description, canonical link, JSON-LD, and a "How to play" section into the root element. React then replaces that HTML on load, and crawlers get a real page.
"Couldn't fetch" on the sitemap. With not_found_handling: "single-page-application", a missing or misrouted /sitemap.xml falls back to index.html with a 200 OK, which is not XML. The Worker now generates the sitemap and robots.txt itself from the same list of pages that drives the crawler HTML, so the two can never disagree.
What I would tell myself on day one
- Freeze daily content once it is generated. Seeds give you reproducibility only while the input data stays the same.
- Treat live data sources as unreliable. Isolate them, validate row counts, and keep yesterday's data as the fallback.
- Most "slow" UI is really a timing problem. Key your images, prefetch what comes next, and restore state before the first render.
- Resolve sync conflicts by progress, not by clock.
- One Worker goes a long way. API, static files, crawler HTML, cron jobs, and the sitemap all fit in one deploy.
You can play today's puzzles at moneygames.cc. I would love feedback, especially on difficulty: which game felt too easy or too hard? Ask me anything about the stack in the comments.
Top comments (0)