Small Game Collection Site Retrospective: 6 Architecture Decisions and 4 Unfilled Pits - DEV Community

DEV Community

no momo
no momo

Posted on

Small Game Collection Site Retrospective: 6 Architecture Decisions and 4 Unfilled Pits

This series covered six articles, each focused on a specific technical problem. But when I sat down to write the retrospective, I realized the most valuable thing wasn't "how each problem was solved" — it was what I based each decision on, and which ones held up in hindsight.

Here's the timeline of decisions, with the data I had at the time and my current judgment. At the end, four unresolved pits.
Demo from https://www.playfiddlebops.com/


Decision 1: Start with Zero Backend — games.json + localStorage

The call at the time: MVP doesn't need a database. Static JSON for the game list, localStorage for scores.

Outcome: The decision was right, but with one condition — users won't ask "why can't I see my phone scores on my computer?" Once someone asks that, the zero-backend window is over.

Looking back: The games.json structure (id, title, slug, thumbnail, entry, category) carried through to the backend phase, which means the data model was sound. localStorage lasted about three months, until cross-device sync became a real need.

Lesson: Zero backend doesn't mean "never use a backend." It means "introduce a backend as late as possible." The signal is simple — when users start asking why data doesn't sync, it's time.


Decision 2: Choose Next.js SSG Over Vue + Vite

The call at the time: SEO was a hard constraint. Next.js SSG works out of the box; Vue needs an extra prerender plugin.

Outcome: The decision was right, but for a slightly different reason than I thought.

In the second article I documented the full comparison. Next.js first-load JS was ~85KB, Vue ~50KB. Vue built twice as fast. But Vue's LCP after prerendering was actually slower (1.5s vs 1.2s), because the prerendered HTML still included the full Vue runtime, with higher hydration cost.

The real deciding factor wasn't "whose first-load JS is smaller," but "who makes the facade pattern easier to implement." On day three, when I recorded the full timeline in the Performance panel, I found that the framework choice's impact on first-load performance was far smaller than the iframe initialization strategy's impact. Vue was ultimately abandoned, not because it was slower, but because SEO required extra plugins and the dynamic route prerendering scalability was worse than Next.js's SSG system.

Lesson: In tech selection, don't compare "which is better." Compare "which makes your key constraint easier to implement." My key constraint was the facade pattern, not first-load JS size.


Decision 3: Use the Facade Pattern to Delay iframe Initialization

The call at the time: The game iframe is the heaviest component on the page. It shouldn't initialize on page load.

Outcome: This was the most valuable decision in the entire series.

In the third article I documented the full debugging process. Users waited 1.8 seconds after clicking "Start." I initially thought the iframe was slow to load, made the wrong optimization (added loading="lazy", delayed setting src), and it got slower after the click.

Recording the full timeline in the Performance panel revealed the root cause: multiple tasks running back-to-back on the main thread. React hydration cleanup, ad SDK initialization, analytics scripts, thumbnail processing, game SDK initialization, WebGL context creation. Total about 266ms, plus browser overhead, showing up as 480ms INP.

The fix was priority scheduling + yield. INP dropped from 480ms to 120ms, at the cost of 18KB more first-load JS.

One detail worth calling out: the difference between scheduler.yield() and setTimeout. setTimeout puts the continuation at the end of the task queue, where it can get stuck behind unimportant tasks. scheduler.yield() puts the continuation in a priority queue, resuming before other same-level tasks. It's available in Chrome 129+, with fallbacks needed for Safari and Firefox.

Lesson: Performance optimization isn't "make code run faster." It's "make the right code run at the right time." Deleting tasks is often unrealistic. Moving when they run is the key.


Decision 4: iframe sandbox with a Separate Domain + Least Privilege

The call at the time: Third-party games are untrusted. They must be isolated with sandbox.

Outcome: The decision was right, but there was a near-miss.

In the fourth article I documented item-by-item sandbox attribute testing. The most critical finding: when the embedded page is same-origin with the parent page, setting both allow-scripts and allow-same-origin allows the embedded page to simply remove the sandbox attribute and reload itself, effectively breaking out of the sandbox entirely.

This means if I had deployed games under a same-origin path, sandbox would have been nearly useless. The solution was to deploy games on a separate domain. Even with allow-same-origin enabled, game scripts can only access their own origin.

The final sandbox configuration was allow-scripts allow-same-origin allow-forms allow-popups, plus allow for fullscreen, autoplay, gamepad, and pointer lock.

Cost: One game relied on allow-modals to show native dialogs. After being blocked by sandbox, it was changed to an HTML overlay. This is a conscious trade-off — security over convenience.

Lesson: sandbox security depends on whether the game runs on a separate domain. Same-origin + allow-same-origin is an easy-to-miss security hole.


Decision 5: Use Combined JSON-LD Types for Rich Results

The call at the time: VideoGame type alone should be enough for a game page.

Outcome: It wasn't. Google's official documentation clearly states: for software applications, Google won't show richer search results for structured data that only contains the VideoGame type. You need to combine it with other available node types, like MobileApplication.

The fixed JSON-LD:

{
  "@context": "https://schema.org",
  "@type": ["VideoGame", "MobileApplication"],
  "name": "Typing Hero",
  "applicationCategory": "Game",
  "operatingSystem": "Web",
  "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.2",
    "ratingCount": "128"
  },
  "gamePlatform": ["Web Browser", "Mobile"]
}
Enter fullscreen mode Exit fullscreen mode

Another key finding: Game page SEO content strategy differs from a regular blog. A SitePoint forum discussion put it well — users searching for a game page have the intent "I want to play this game." Stacking 1,500 words above the game actually makes the page worse. The right approach is to add short controls, tips, and FAQ below the game. Content that satisfies search intent is enough; there's no word count target.

Lesson: Game page SEO is "help search engines understand what the page is about," not "stack content volume." Combined JSON-LD types and short structured content are more effective than long articles.


Decision 6: SQLite + Single-Writer Funnel

The call at the time: SQLite is zero ops and enough for a personal project.

Outcome: SQLite was enough, but only if you understand its concurrency model.

In the sixth article I documented three traps: direct UPDATE overwriting scores, SQLITE_BUSY lock errors, and unstable same-score ranking.

The key fixes were enabling WAL mode (read/write concurrency, writers don't block readers) and using a single-writer funnel (Promise chain serializing all writes). Load test data went from 97% success at 100 concurrency to 100%, and P95 response time from 180ms to 45ms.

But: Serializing writes means throughput has a ceiling. In a local environment, a single writer with SQLite handles roughly 800–1200 writes/second. If the game site grows to thousands of submissions per second, SQLite isn't enough.

Lesson: SQLite's problem was never "performance." It's "whether you serialized writes correctly." In a single-writer model, 800–1200 writes/second is far more than enough for a personal project.


Performance Budget

Based on the series' measured data, here's my current performance budget:

Metric Target Measured
LCP (4G) ≤ 2.5s 1.2s (static pages) / 2.1s (game detail pages)
INP (click start) ≤ 200ms 120ms (Chrome) / 220ms (Safari)
CLS ≤ 0.1 0.04
First-load JS (gzip) ≤ 150KB 103KB
Main thread long tasks (2s after click) ≤ 3 2 (was 6 before optimization)

Core Web Vitals thresholds are LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1, measured at the 75th percentile. INP replaced FID in March 2024 and measures the full time from input to next paint, not just first interaction.

One easy-to-miss point: Core Web Vitals ranking signals are binary — 199ms passes, 201ms fails. So the performance budget needs headroom, not a target right at the threshold.


SEO Checklist

Item Status Notes
URL structure /games/{category}/{slug} Done Semantic, category and game identifier in URL
JSON-LD VideoGame + MobileApplication Done Combined types needed for rich results
generateMetadata server-side rendering Done Client-side dynamic metadata isn't crawlable
canonical pointing to source site Done dev.to manual, Medium via Import
robots.txt disallow /play/ Done Game runtime page is an iframe container, no independent content value
sitemap auto-generated Done Game pages weekly, homepage daily
CLS optimization (aspect-ratio reservation) Done CLS dropped from 0.32 to 0.04
Game page content (controls + tips + FAQ) Partial Full content only for top 20% of games by traffic
hreflang Not done Currently only Chinese and English, no multilingual versions

An important lesson from OYNAVA's retrospective: When all locale URLs return identical content, emitting hreflang for 8 languages makes Google discover 8x duplicate URLs and waste crawl budget. Removing hreflang and keeping only canonical lets crawlers refocus on real pages. Before doing multilingual, I'll confirm each locale's content is actually different before emitting hreflang.


Backend Evolution Path

Stage User count Write frequency Solution
Static JSON < 20 games None Zero backend, local score storage
SQLite + Node.js < 1000 users < 500/sec WAL mode + single-writer funnel
PostgreSQL + Redis > 5000 users > 1000/sec Redis real-time ranking, PostgreSQL persistence
Headless CMS Content management needs — Non-technical people edit game list

The key judgment isn't "which technology is more advanced," but whether user count and write frequency have exceeded the current solution's comfort zone. SQLite's single-writer constraint means it stops being suitable when "multiple app servers access the same database simultaneously" — when you have a background worker writing at the same time as the web server, or multiple users modifying shared data at once, that constraint becomes a bottleneck.


4 Unfilled Pits

Pit 1: Anti-Cheat

Client-submitted scores have no server-side validation. Users can forge requests to submit arbitrary scores. This is the biggest security gap right now. Needs server-side score sanity checks, or moving game logic to the server.

Pit 2: iOS Safari WebGL Memory Issue

One WebGL game crashes to a white screen after about 5 minutes on iPhone. WebKit's bug tracker shows iOS Safari has an iframe WebGL memory leak issue. Currently falls back to limiting game session length and prompting the user to refresh.

Pit 3: Multilingual SEO

Currently only Chinese and English versions, no hreflang. If going international, each language version needs to have genuinely different content before emitting hreflang, otherwise it wastes crawl budget like OYNAVA's case.

Pit 4: Build Bottleneck as Game Count Grows

generateStaticParams pre-generates all game pages at build time. If the game count reaches 200+, build time increases significantly. Needs ISR or a Headless CMS, but that's no longer zero-backend.


The Biggest Surprise

After writing six articles, I realized the biggest surprise was that framework choice mattered far less than task scheduling.

In the second article, I agonized between Next.js and Vue for three days. In the third article, I found the real performance bottleneck was main thread task contention, almost unrelated to the framework. The same facade pattern works in both frameworks. The same priority scheduling works in both.

Tech selection decisions look important, but what really determines user experience is whether you understand the browser's resource model — the main thread is a shared resource, and timing matters almost as much as size.


References

  • Google Search Central documentation on SoftwareApplication structured data, explaining that VideoGame type needs to be combined to get rich results
  • web.dev guide on Core Web Vitals thresholds, LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1
  • SitePoint forum discussion on HTML5 game page SEO, game page content strategy should match search intent rather than blog standards
  • OYNAVA developer retrospective, covering SSR cost traps, N+1 requests, and hreflang duplicate content lessons
  • Spielcade developer retrospective, covering technical SEO challenges across 17,000+ game pages and the design thinking behind the "Surprise Me" feature

Top comments (0)