How to Build the Apple Game: v0 to Deployed
Here’s how to build the Apple Game, version by version, from v0 to deployed. It started with “give me 10 game ideas”: simple but still makes you think, fun for every age, good for a kid’s development or an older adult’s memory, and understandable without a single instruction. From there it grew to v13 in a few hours. This is the third experiment after Times Table Drop, and you can play the result at Apple Game: Make 10.
v0: picking the Apple Game out of ten candidates
Out of ten candidates, the pick was the drag-a-rectangle-to-make-10 puzzle that went viral across Korea. Two reasons: it’s a format with proven fun and replay value, and it needs zero rule explanation — drag a rectangle, watch whether it clears, and you understand the whole game in five seconds.
v1: true to the original — a cleared cell just left a hole
The first version followed the original rules exactly. A cleared cell stayed empty, and the round ended the moment no rectangle summed to 10 anymore. A two-minute timer, three difficulty presets, a hint that auto-appeared after five seconds (looping forever), sound effects, and a localStorage best score were all already in place.
v2: refill and a time reward
The board could still be full of apples and the round would end anyway the instant combinations ran dry, so it felt like it “ended too easily.” Cleared cells started refilling immediately, and every clear banked a little bonus time (0.3s per cell). A stuck board also got a silent auto-shuffle safety net here — no usage limit yet.
v3: the board shrinks to a quarter
A request to halve both width and height landed on Easy 4×5 (20 cells), Normal 5×7 (35 cells), Hard 6×8 (48 cells) — exactly a quarter of the original 8×10, 10×13, 12×16.
v4: end the game when it’s truly stuck
A smaller board meant genuinely unsolvable states could happen more often. So if a shuffle still leaves zero way to make 10 (the number composition itself is impossible), the game now ends right there.
Debugging story 1: the number on screen wasn’t the number being counted
Adding refills, the logical grid value updated instantly while the on-screen number only caught up 260ms later, once the pop animation finished — meant to keep things safe before the vanish animation completed. That created the opposite problem: during that 260ms window, the screen still showed the old number while the sum was already computed against the new one. Fast players hit this constantly. It surfaced as a bug report: “sometimes the number shown and the number actually used don’t match.”
v5: change the display and the value at the same instant
The fix: flip the display and the logical value at the exact same moment. Both stay at the old value while the animation plays, and only switch together, 260ms later. A sweep across 15 rounds — 525 checks — turned up zero mismatches after that.
v6: shuffle and hint, cut down to “once”
Around here came a question about when hints appear and whether infinite shuffling was possible. Explaining the mechanism apparently wasn’t the answer wanted, because the rules changed outright: the shuffle became usable exactly once per game, and the auto-appearing hint got replaced with a button that shows it once (a single 2.8-second animation cycle) per press. A stuck board with zero combinations would now pop a notice instead of just quietly moving on — still built on plain alert() at this point.
v7: alert() gives way to a confirm modal
“That just changed instantly. Show a notice, and only shuffle once I hit confirm.” alert() can slide through without an actual confirmation depending on the browser, so it wasn’t trustworthy. The result screen’s own modal shell got reused instead — shuffling or ending the game only ever fires from inside a confirm-button click now.
v8: one more chance when time runs out, too
To “if a shuffle is still unused, shouldn’t the game not just end outright at the very end?” the answer came back “both.” Not just a stuck board — the moment the timer hits zero with the shuffle unused, the game now offers a notice with +15s bonus time instead of ending.
v9: big numbers were stranding their neighbors
After the board shrank to a quarter size came a note that a 9 turns its neighbors into dead weight the moment it appears. A 9 can only ever pair with a 1 (9+1=10; anything more overshoots), so that’s exactly what was happening. Weighting the number distribution toward smaller values (1 at 9x the weight of a 9) fixed it — 90,000 sampled draws afterward landed at 20% for 1 and 2.1% for 9, exactly as designed.
v10: a start cover and a dramatic timer
“Hide the board behind a start button, put the timer big and centered, warn past 20 seconds, get more dramatic past 10.” A cover with a “Start” button went over the board, and the timer moved out of the score row into its own large, centered element — amber pulse under 20 seconds, red heartbeat pulse plus a tick sound under 10.
v11: a more generous reward
“Even playing fast, the timer eventually runs out anyway” doubled the per-cell time bonus from 0.3s to 0.6s. Simulating a fast pace (20 rounds, 45 cells cleared, 6 seconds of wall time) showed 27 seconds of bonus banked against 6 seconds elapsed — a net gain of +20.9 seconds.
Debugging story 2: the timer kept ticking behind the modal
“Shouldn’t the existing timer pause while a notice is up?” led to pauseTimer()/resumeTimer() — pause when a notice appears, resume with the paused duration credited back once confirmed. That’s where a real bug turned up: when notices chain (stuck → shuffle fails to fix it → end-game notice), the first notice’s confirm handler resumed the timer unconditionally, so it kept quietly running even while the second notice sat on screen.
v12: hand resume duty to the last notice standing
The fix checks whether a new notice appeared right after confirm, and hands resume duty to that one if so. It only got trusted after clicking through both stages by hand and confirming the clock stayed frozen the whole way. A question about persisting the best score across sessions came up around here too, but it never landed on a concrete scope before the conversation moved on — that one’s still unresolved.
v13: shipping it to the site
“Put this up on sonnyplot.com.” The standalone HTML file got ported into an Astro component with the same structure as Times Table Drop: moved the game’s internal CSS to global with an .a10 prefix (since JS-built apple cells and effects never get Astro’s scoped styling), wrapped everything in an IIFE, and rewired buttons through addEventListener instead of global onclick. It shipped bilingual, at /apple-game and /kr/apple-game.
Text that vanished in light mode. The original game painted an entire dark background and used near-white text (#fff6ef) on top of it. Embedded in the site, though, the score and timer sit outside the game board — on the site’s own background. In light mode that’s white text on white, and the timer simply disappeared. Only caught by switching the local dev server to light mode directly. The dark “arcade cabinet” palette stayed inside the board and modals; everything outside it (score, timer, progress bar) switched to the site’s own light/dark-adaptive color variables.
The build passed but the deploy didn’t. Ten minutes after pushing, sonnyplot.com still hadn’t changed. A clean local rebuild (rm -rf node_modules && npm ci) passed without error, and JSON syntax, filename casing, and asset sizes all checked out clean. The Cloudflare dashboard finally showed it: npm run build was green, but the deploy step right after (npx wrangler deploy) was red. The deploy step itself couldn’t be reproduced locally — not logged into wrangler — so the actual error was never seen, but retrying it went through cleanly.
The result
Play it at Apple Game: Make 10. The board stays hidden until you press “Start.” The timer is large and centered — amber at 20 seconds, then a red heartbeat pulse with a tick sound under 10. Clearing apples banks bonus time, and a stuck board or an expiring clock each earn exactly one shuffle.
What I took from it
Laid out version by version, both real bugs (v1’s display/value gap, v12’s notice-resume gap) trace back to the same thing: a gap between what’s on screen and what the system is actually computing. Anywhere you introduce a “wait a moment” — an animation, a modal — it’s worth sketching out separately what the user is looking at versus what the system is actually doing at that exact instant. And each individual request was small; stacked up, they still reached v13. That’s just what development driven by a steady stream of small feedback looks like.