From Web Dev to Google Play: Building a Word Game With Nuxt
Josh Collinsworth has been building web-based games for years — recreations of classics like color flood, hangman, and Connect Four as CodePen demos and side projects. But with his latest project, Quina, he wanted something bigger: a real, downloadable mobile game. Quina is a word game variation on Mastermind (which itself derives from a classic pen-and-paper game) where players guess a secret five-letter word with no repeated letters, using clues to refine their guesses within a 10-attempt limit. The name comes from the Latin for "five at a time." Built over roughly four months of evenings and weekends, the project became a lesson in shipping a Vue-based web app to the Google Play Store.
Why Nuxt Fits the Bill
Collinsworth, a self-described huge Vue fan, chose Nuxt for this project to go deeper into the Vue ecosystem. As the Vue counterpart to Next.js, Nuxt offered three key advantages:
- Static compilation: Nuxt can compile to a fully static app, eliminating any need to manage Node servers.
- Simplified routing and components: Dropping Vue components into a
/pagesfolder handles routing, and Nuxt auto-imports components without explicit registration. - First-class PWA support: The official Nuxt PWA module packages service worker capabilities and other progressive web app features out of the box.
The available Nuxt modules proved valuable beyond the PWA setup. The Nuxt Content module, which allows page content to be written in Markdown or a mix of Markdown and Vue components, made building the "How to Play" and FAQs pages much faster than hand-coding HTML.
Simulating a Native Experience
Since Quina was destined for the Google Play Store, the app needed to feel like a native application from the start. That meant supporting an optional dark mode and a reduced-motion setting, plus full-page transitions between sections.
Both settings live as booleans in the app's Vuex store. When enabled, each adds a specific class to Nuxt's default layout wrapper. Since all pages share the same global store, preferences set in one area — like the settings page — seamlessly apply elsewhere (such as the game screen). All settings sync to localStorage as well, preserving values between sessions without requiring repeated lookups from storage.
Implementing page transitions is straightforward in Nuxt. A single transition property in a page's Vue file applies a named transition class when navigating to or from that page. The author's page-slide SCSS uses CSS transform-based animations, but the .reduce-motion class overrides that by disabling transforms whenever the user has indicated a preference for reduced motion — either through the OS media query or the app's own setting. Opacity fades remain, since they aren't perceived as movement.
Handling Idle Browser Tabs
One unexpected issue surfaced around routing: if a user left Quina idle in a browser tab for a while, the JavaScript powering the Vue Router would stop running. When they returned and clicked a link, the app treated the URL as relative and produced a 404 — for instance, clicking from /faq to /options would attempt /faq/options.
The solution: a custom error.vue page (which Nuxt automatically uses for all errors) that validates the incoming path and redirects to its last segment. This works because Quina avoids nested routes; if the final path is invalid, the app still lands on a proper 404.
Adding Vibration and Sound
Vibration is achievable in modern browsers via window.navigator.vibrate(), letting a game add brief tactile feedback (or a short buzz to mimic haptic key presses). But it must be used carefully: too much vibration is a negative experience, and not every browser supports it. Calling the function where it doesn't exist can crash the current script.
The author guarded against this with a Vuex getter that checks three conditions: the user hasn't disabled vibration in settings, the current context is the client (process.client, which Nuxt provides for isolating browser-only code), and the vibrate function actually exists in the current browser.
Sound was more problematic than expected. The author mixed free game sound samples from several artists, letting users control volume or disable audio entirely. However, Safari (especially on iOS) had extremely laggy audio playback — all clicks and dings played noticeably late after triggering events. The fix was Howler.js, a library that solved the issue with minimal code changes, though why it works where other approaches failed remains something of a mystery. For any JavaScript app using synchronous sound, the author recommends Howler as a dependable solution.
Gameplay Depth: Difficulty, Statistics, and Achievements
To keep Quina engaging for different skill levels (and English proficiency levels), the game offers:
- Word sets: Basic (common English words), Tricky (more obscure or harder to spell words), or Random (a weighted mix of the two)
- Starting hints: Players can opt to receive a hint before each game, with configurable reveal strength
These options range from the easiest experience (Basic words with strong hints) to the hardest (Tricky or Random with none). The app records each game as an object in Vuex state, synced to localStorage in a gameHistory array. Vuex getters then compute statistics like win/loss ratio and average guesses using standard array methods such as .filter() and .reduce(). One getter tracks the user's longest win streak, which the author notes was particularly complex to implement.
Achievements ("awards" in the app) follow a common pattern: an array of award objects, each tied to a Vuex getter and a requirement.threshold property that unlocks when the getter's value crosses that threshold. The template loops over these objects to render progress gauges, filling them as players approach each goal. Quina ships with 25 awards — a nod to 5 × 5 — for feats like winning games, trying all modes, or winning within the first three guesses. The latter is called "Lucky," and as an Easter egg, every achievement name is a potential code word (five letters, no repeats).
The Pros and Cons of Web Tech in an App Shell
The "build once, deploy everywhere" strategy has real trade-offs. On the positive side: you deploy to an app store once, everything else is just a website update (much faster than store review cycles), and there's only ever one codebase. Since every platform renders with a browser engine, the behavior is predictable.
But that single codebase must handle every input type simultaneously — tapping, clicking, long presses, and keyboard keys can all trigger different responses on the same elements, making event handling tricky. The app experience also needed to be split for Android-only versus web-only features. And while you don't worry about Android versions, the default browser (usually Chrome) is something to plan for.
Turning a PWA Into an Android App: TWA and Bubblewrap
Rather than rebuild in a framework like React Native — which requires learning an entirely new ecosystem — the author used a Trusted Web Activity (TWA) approach. A TWA is essentially an Android app that contains no real native internals; it's a browser that points to a specific URL, loading a website full-screen minus any browser controls. This effectively disguises a PWA as a native app.
TWAs require the web app to be a valid PWA, validated via Lighthouse or the Bubblewrap CLI tool. The developer must generate the APK themselves (Bubblewrap accomplishes this even without native development experience) and must have matching signing keys between the Android app and the web app at a specific URL. Those keys produce the "trusted" part: the verification ensures the app loads the correct website. Without proper key verification, the app falls back to a plain web browser view, complete with UI chrome.
Bubblewrap, a CLI tool installed via npm i -g @bubblewrap/cli, turns a PWA into an APK. During setup, it will ask for the URL of the site's manifest.json file (a PWA requirement). Notably, Nuxt's PWA module appends a unique UUID to that file's name with each build. The tool also performs PWA validation by default, though it may fail this check incorrectly in some cases — the author found it necessary to add the --skipPwaValidation flag despite Lighthouse confirming the app's PWA status.
Bubblewrap can install the Java Development Kit (JDK) and Android Software Development Kit (SDK), but expects them at exact paths. The bubblewrap doctor command verifies installation. During configuration, developers answer questions about app appearance (colors, icons, splash screen) and settings. A few items deserve attention:
- Application ID: A 2–3 part dot-separated string that's functionally a convention, but becomes part of the Google Play Store URL. It can never be reused, so it's worth getting right the first time.
- Starting version: Play requires incremental versions with each upload; starting at 0 or 1 is recommended.
- Display mode: Options include
standalone(full-screen with the native status bar) andfullscreen. The defaultstandaloneis generally fine.
The Critical Signing Key
The signing key is the single most important piece of the TWA setup. It's what connects the PWA to the native app shell. The author warns to carefully track all key names and passwords, since command-line creation via tools like keytool may interpret special characters unexpectedly, even within password prompts. An alternative is allowing Google to supply the key via the Play Console. Official Android documentation covers app signing thoroughly, while verifying the link between your app and site requires placing a /.well-known/assetlinks.json file at the root of the site, containing the key hash and related details that Google checks.
Google Play Review Realities
Getting listed isn't free or quick. A one-time $25 USD fee grants eligibility. Reviews are tedious (several forms and app screenshots required before review begins) and delays can be significant; the Google Play Console dashboard even displays a "longer than usual review times" notice that, per the author, has been up for over six months. Anything changed in a listing triggers a fresh review, including updates to screenshots. You'll also need terms of service and privacy policy links (Collinsworth wrote both only to satisfy this requirement).
Nothing is reversible: a free app can't become paid even before public release, bundles can't be overwritten or deleted, and other settings may be stuck what you set them once. Approaching with a "just ship it and fix it later" attitude, the author says they had to start over at least once or twice meeting these constraints.
Google's payment policies are the most severe constraint. With few exceptions, any in-app payments must route through Google Play Billing, which charges a percentage fee (30% at time of development, lowered to 15% since). Stripe, Square, PayPal and similar processors are disallowed for in-app digital transactions. The author's original plan — a free app with an optional support page — got immediately flagged for violating this policy, despite its leniency at the time.
The eventual workaround: charging $2.99 for the app itself on Google Play, not selling virtual goods within the app. Android users get all content unlocked by default, since they effectively pay upfront. Web players can still access a separate support mechanism.
Adapting the Experience Per Platform
Android apps transmit a custom header containing the app's unique ID when requesting websites. Quina checks for that header to set a Vuex boolean (isAndroid), which cascades into UI changes throughout the app. This drives simple <WebOnly> and <AndroidOnly> Vue wrapper components that selectively show FAQ answers (e.g., no support-payment questions for Android users), hide the support page in menus for Android players, and unlock all content by default.
No Login, No Server: The Choice Against Accounts
For a while, the project used Firebase for user accounts and cross-device data syncing. The idea was to let players track stats anywhere they played. Ultimately, the author dropped it for several reasons: complexity of maintaining a secure accounts system, the responsibility of holding users' personal data, and — most importantly — the simple security promise of localStorage. With no login and no server-held data, Quina can honestly guarantee zero risk of compromised data. The trade-off: no portability of game history between browsers or devices, though this does give players a chance to re-earn achievements. It also means the creator avoids compliance concerns like cookie warnings entirely.
Shipping a web app to Google Play is achievable for a front-end developer with the TWA plus Bubblewrap path. It involves far more steps than a conventional web deployment and many venues to trip. But as the author demonstrated, it's feasible.



