What I Learned Building a Word Game App With Nuxt on Google Play

Avatar of Josh Collinsworth
Josh Collinsworth on

DigitalOcean provides cloud products for every stage of your journey. Get started with $200 in free credit!

I fell in love with coding the moment I created my first CSS :hover effect. Years later, that initial bite into interactivity on the web led me to a new goal: making a game.


Those early moments playing with :hover were nothing special, or even useful. I remember making a responsive grid of blue squares (made with float, if that gives you an idea of the timeline), each of which turned orange when the cursor moved over them. I spent what felt like hours mousing over the boxes, resizing the window to watch them change size and alignment, then doing it all over again. It felt like pure magic.

What I built on the web naturally became more complex than that grid of <div> elements over the years, but the thrill of bringing something truly interactive to life always stuck with me. And as I learned more and more about JavaScript, I especially loved making games.

Sometimes it was just a CodePen demo; sometimes it was a small side project deployed on Vercel or Netlify. I loved the challenge of recreating games like color flood, hangman, or Connect Four in a browser.

After a while, though, the goal got bigger: what if I made an actual game? Not just a web app; a real live, honest-to-goodness, download-from-an-app-store game. Last August, I started working on my most ambitious project to date, and four months later, I released it to the world (read: got tired of fiddling with it): a word game app that I call Quina.

What’s the game (and what’s that name)?

The easiest way to explain Quina is: it’s Mastermind, but with five-letter words. In fact, Mastermind is actually a version of a classic pen-and-paper game; Quina is simply another variation on that same original game.

The object of Quina is to guess a secret five-letter word. After each guess, you get a clue that tells you how close your guess is to the code word. You use that clue to refine your next guess, and so on, but you only get ten total guesses; run out and you lose.

Example Quina gameplay

The name “Quina” came about because it means “five at a time” in Latin (or so Google told me, anyway). The traditional game is usually played with four-letter words, or sometimes four digits (or in the case of Mastermind, four colors); Quina uses five-letter words with no repeated letters, so it felt fitting that the game should have a name that plays by its own rules. (I have no idea how the original Latin word was pronounced, but I say it “QUINN-ah,” which is probably wrong, but hey, it’s my game, right?)

I spent my evenings and weekends over the course of about four months building the app. I’d like to spend this article talking about the tech behind the game, the decisions involved, and lessons learned in case this is a road you’re interested in traveling down yourself.

Choosing Nuxt

I’m a huge fan of Vue, and wanted to use this project as a way to expand my knowledge of its ecosystem. I considered using another framework (I’ve also built projects in Svelte and React), but I felt Nuxt hit the sweet spot of familiarity, ease of use, and maturity. (By the way, if you didn’t know or hadn’t guessed: Nuxt could be fairly described as the Vue equivalent of Next.js.)

I hadn’t gone too deep with Nuxt previously; just a couple of very small apps. But I knew Nuxt can compile to a static app, which is just what I wanted — no (Node) servers to worry about. I also knew Nuxt could handle routing as easily as dropping Vue components into a /pages folder, which was very appealing.

Plus, though Vuex (the official state management in Vue) isn’t terribly complex on its own, I appreciated the way that Nuxt adds just a little bit of sugar to make it even simpler. (Nuxt makes things easy in a variety of ways, by the way, such as not requiring you to explicitly import your components before you can use them; you can just put them in the markup and Nuxt will figure it out and auto-import as needed.)

Finally, I knew ahead of time I was building a Progressive Web App (PWA), so the fact that there’s already a Nuxt PWA module to help build out all the features involved (such as a service worker for offline capability) already packaged up and ready to go was a big draw. In fact, there’s an impressive array of Nuxt modules available for any unseen hurdles. That made Nuxt the easiest, most obvious choice, and one I never regretted.

I ended up using more of the modules as I went, including the stellar Nuxt Content module, which allows you to write page content in Markdown, or even a mixture of Markdown and Vue components. I used that feature for the “FAQs” page and the “How to Play” page as well (since writing in Markdown is so much nicer than hard-coding HTML pages).

Achieving native app feel with the web

Quina would eventually find a home on the Google Play Store, but regardless of how or where it was played, I wanted it to feel like a full-fledged app from the get-go.

To start, that meant an optional dark mode, and a setting to reduce motion for optimal usability, like many native apps have (and in the case of reduced motion, like anything with animations should have).

Under the hood, both of the settings are ultimately booleans in the app’s Vuex data store. When true, the setting renders a specific class in the app’s default layout. Nuxt layouts are Vue templates that “wrap” all of your content, and render on all (or many) pages of your app (commonly used for things like shared headers and footers, but also useful for global settings):

<!-- layouts/default.vue -->
<template>
  <div
    :class="[
      {
        'dark-mode': darkMode,
        'reduce-motion': reduceMotion,
      },
      'dots',
    ]"
  >
    <Nuxt />
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters(['darkMode', 'reduceMotion']),
  },
  // Other layout component code here
}
</script>

Speaking of settings: though the web app is split into several different pages — menu, settings, about, play, etc. — the shared global Vuex data store helps to keep things in sync and feeling seamless between areas of the app (since the user will adjust their settings on one page, and see them apply to the game on another).

Every setting in the app is also synced to both localStorage and the Vuex store, which allows saving and loading values between sessions, on top of keeping track of settings as the user navigates between pages.

And speaking of navigation: moving between pages is another area where I felt there was a lot of opportunity to make Quina feel like a native app, by adding full-page transitions.

Nuxt page transitions in action

Vue transitions are fairly straightforward in general—you just write specifically-named CSS classes for your “to” and “from” transition states—but Nuxt goes a step further and allows you to set full page transitions with only a single line in a page’s Vue file:

<!-- A page component, e.g., pages/Options.vue -->
<script>
export default {
  transition: 'page-slide'
  // ... The rest of the component properties
}
</script>

That transition property is powerful; it lets Nuxt know we want the page-slide transition applied to this page whenever we navigate to or away from it. From there, all we need to do is define the classes that handle the animation, as you would with any Vue transition. Here’s my page-slide SCSS:

/* assets/css/_animations.scss */

.page-slide {
  &-enter-active {
    transition: all 0.35s cubic-bezier(0, 0.25, 0, 0.75);
  }

  &-leave-active {
    transition: all 0.35s cubic-bezier(0.75, 0, 1, 0.75);
  }

  &-enter,
  &-leave-to {
    opacity: 0;
    transform: translateY(1rem);

    .reduce-motion & {
      transform: none !important;
    }
  }

  &-leave-to {
    transform: translateY(-1rem);
  }
}

Notice the .reduce-motion class; that’s what we talked about in the layout file just above. It prevents visual movement when the user has indicated they prefer reduced motion (either via media query or manual setting), by disabling any transform properties (which seemed to warrant usage of the divisive !important flag). The opacity is still allowed to fade in and out, however, since this isn’t really movement.

Comparing default motion (left) with reduced motion (right)

Side note on transitions and handling 404s: The transitions and routing are, of course, handled by JavaScript under the hood (Vue Router, to be exact), but I ran into a frustrating issue where scripts would stop running on idle pages (for example, if the user left the app or tab open in the background for a while). When coming back to those idle pages and clicking a link, Vue Router would have stopped running, and so the link would be treated as relative and 404.

Example: the /faq page goes idle; the user comes back to it and clicks the link to visit the /options page. The app would attempt to go to /faq/options, which of course doesn’t exist.

My solution to this was a custom error.vue page (this is a Nuxt page that automatically handles all errors), where I’d run validation on the incoming path and redirect to the end of the path.

// layouts/error.vue
mounted() {
  const lastPage = '/' + this.$route.fullPath.split('/').pop()
  // Don't create a redirect loop
  if (lastPage !== this.$route.fullPath) {
    this.$router.push({
      path: lastPage,
    })
  }
}

This worked for my use case because a) I don’t have any nested routes; and b) at the end of it, if the path isn’t valid, it still hits a 404.

Vibration and sound

Transitions are nice, but I also knew Quina wouldn’t feel like a native app — especially on a smartphone — without both vibration and sound.

Vibration is relatively easy to achieve in browsers these days, thanks to the Navigator API. Most modern browsers simply allow you to call window.navigator.vibrate() to give the user a little buzz or series of buzzes — or, using a very short duration, a tiny bit of tactile feedback, like when you tap a key on a smartphone keyboard.

Obviously, you want to use vibration sparingly, for a few reasons. First, because too much can easily become a bad user experience; and second, because not all devices/browsers support it, so you need to be very careful about how and where you attempt to call the vibrate() function, lest you cause an error that shuts down the currently running script.

Personally, my solution was to set a Vuex getter to verify that the user is allowing vibration (it can be disabled from the settings page); that the current context is the client, not the server; and finally, that the function exists in the current browser. (ES2020 optional chaining would have worked here as well for that last part.)

// store/getters.js
vibration(state) {
  if (
    process.client &&
    state.options.vibration &&
    typeof window.navigator.vibrate !== 'undefined'
  ) {
    return true
  }
  return false
},

Side note: Checking for process.client is important in Nuxt — and many other frameworks with code that may run on Node — since window won’t always exist. This is true even if you’re using Nuxt in static mode, since the components are validated in Node during build time. process.client (and its opposite, process.server ) are Nuxt niceties that just validate the code’s current environment at runtime, so they’re perfect for isolating browser-only code.

Sound is another key part of the app’s user experience. Rather than make my own effects (which would’ve undoubtedly added dozens more hours to the project), I mixed samples from a few artists who know better what they’re doing in that realm, and who offered some free game sounds online. (See the app’s FAQs for full info.)

Users can set the volume they prefer, or shut the sound off entirely. This, and the vibration, are also set in localStorage on the user’s browser as well as synced to the Vuex store. This approach allows us to set a “permanent” setting saved in the browser, but without the need to retrieve it from the browser every time it’s referenced. (Sounds, for example, check the current volume level each time one is played, and the latency of waiting on a localStorage call every time that happens could be enough to kill the experience.)

An aside on sound

It turns out that for whatever reason, Safari is extremely laggy when it comes to sound. All the clicks, boops and dings would take a noticeable amount of time after the event that triggered them to actually play in Safari, especially on iOS. That was a deal-breaker, and a rabbit hole I spent a good amount of hours despairingly tunneling down.

Fortunately, I found a library called Howler.js that solves cross-platform sound issues quite easily (and that also has a fun little logo). Simply installing Howler as a dependency and running all of the app’s sounds through it — basically one or two lines of code — was enough to solve the issue.

The Howler.js logo, featuring a cute little howler monkey wearing headphones

If you’re building a JavaScript app with synchronous sound, I’d highly recommend using Howler, as I have no idea what Safari’s issue was or how Howler solves it. Nothing I tried worked, so I’m happy just having the issue resolved easily with very little overhead or code modification.

Gameplay, history, and awards

Quina can be a difficult game, especially at first, so there are a couple of ways to adjust the difficulty of the game to suit your personal preference:

  1. You can choose what kind of words you want to get as code words: Basic (common English words), Tricky (words that are either more obscure or harder to spell), or Random (a weighted mix of the two).
  2. You can choose whether to receive a hint at the start of each game, and if so, how much that hint reveals.
Quina offers several different ways to play, to accommodate players of all skill levels

These settings allow players of various skill, age, and/or English proficiency to play the game on their own level. (A Basic word set with strong hints would be the easiest; Tricky or Random with no hints would be the hardest.)

“Soft hints” reveal one letter in the code word (but not its position)

While simply playing a series of one-off games with adjustable difficulty might be enjoyable enough, that would feel more like a standard web app or demo than a real, full-fledged game. So, in keeping with the pursuit of that native app feel, Quina tracks your game history, shows your play statistics in a number of different ways, and offers several “awards” for various achievements.

Quina tracks the results of all games played, your longest win streaks, and many other stats

Under the hood, each game is saved as an object that looks something like this:

{
  guessesUsed: 3,
  difficulty: 'tricky',
  win: true,
  hint: 'none',
}

The app catalogues your games played (again, via Vuex state synced to localStorage) in the form of a gameHistory array of game objects, which the app then uses to display your stats — such as your win/loss ratio, how many games you’ve played, and your average guesses — as well as to show your progress towards the game’s “awards.”

This is all done easily enough with various Vuex getters, each of which utilizes JavaScript array methods, like .filter() and .reduce(), on the gameHistory array. For example, this is the getter that shows how many games the user has won while playing on the “tricky” setting:

// store/getters.js
trickyGamesWon(state) {
  return state.gameHistory.filter(
    (game) => game.win && game.difficulty === 'tricky'
  ).length
},

There are many other getters of varying complexity. (The one to determine the user’s longest win streak was particularly gnarly.)

Adding awards was a matter of creating an array of award objects, each tied to a specific Vuex getter, and each with a requirement.threshold property indicating when that award was unlocked (i.e., when the value returned by the getter was high enough). Here’s a sample:

// assets/js/awards.js
export default [
  {
    title: 'Onset',
    requirement: {
      getter: 'totalGamesPlayed',
      threshold: 1,
      text: 'Play your first game of Quina',
    }
  },
  {
    title: 'Sharp',
    requirement: {
      getter: 'trickyGamesWon',
      threshold: 10,
      text: 'Win ten total games on Tricky',
    },
  },
]

From there, it’s a pretty straightforward matter of looping over the achievements in a Vue template file to get the final output, using its requirement.text property (though there’s a good deal of math and animation added to fill the gauges to show the user’s progress towards achieving the award):

Awards are unlocked and displayed if a player’s progress is above the threshold; otherwise, the game displays the progress bar with an indication of how much is left until the award is unlocked.

There are 25 awards in all (that’s 5 × 5, in keeping with the theme) for various achievements like winning a certain number of games, trying out all the game modes, or even winning a game within your first three guesses. (That one is called “Lucky” — as an added little Easter egg, the name of each award is also a potential code word, i.e., five letters with no repeats.)

Unlocking awards doesn’t do anything except give you bragging rights, but some of them are pretty difficult to achieve. (It took me a few weeks after releasing to get them all!)

Pros and cons of this approach

There’s a lot to love about the “build once, deploy everywhere” strategy, but it also comes with some drawbacks:

Pros

  • You only need to deploy your store app once. After that, all updates can just be website deploys. (This is much quicker than waiting for an app store release.)
  • Build once. This is sorta true, but turned out to be not quite as straightforward as I thought due to Google’s payments policy (more on that later).
  • Everything is a browser. Your app is always running in the environment you’re used to, whether the user realizes it or not.

Cons

  • Event handlers can get really tricky. Since your code is running on all platforms simultaneously, you have to anticipate any and all types of user input at once. Some elements in the app can be tapped, clicked, long-pressed, and also respond differently to various keyboard keys; it can be tricky to handle all of those at once without any of the handlers stepping on each other’s toes.
  • You may have to split experiences. This will depend on what your app is doing, but there were some things I needed to show only for users on the Android app and others that were only for web. (I go into a little more detail on how I solved this in another section below.)
  • Everything is a browser. You’re not worried about what version of Android your users are on, but you are worried about what their default browser is (because the app will use their default browser behind the scenes). Typically on Android this will mean Chrome, but you do have to account for every possibility.

Logistics: turning a web app into a native app

There’s a lot of technology out there that makes the “build for the web, release everywhere” promise — React Native, Cordova, Ionic, Meteor, and NativeScript, just to name a few.

Generally, these boil down to two categories:

  1. You write your code the way a framework wants you to (not exactly the way you normally would), and the framework transforms it into a legitimate native app;
  2. You write your code the usual way, and the tech just wraps a native “shell” around your web tech and essentially disguises it as a native app.

The first approach may seem like the more desirable of the two (since at the end of it all you theoretically end up with a “real” native app), but I also found it comes with the biggest hurdles. Every platform or product requires you to learn its way of doing things, and that way is bound to be a whole ecosystem and framework unto itself. The promise of “just write what you know” is a pretty strong overstatement in my experience. I’d guess in a year or two a lot of those problems will be solved, but right now, you still feel a sizable gap between writing web code and shipping a native app.

On the other hand, the second approach is viable because of a thing called “TWA,” which is what makes it possible to make a website into an app in the first place.

What is a TWA app?

TWA stands for Trusted Web Activity — and since that answer is not likely to be helpful at all, let’s break that down a bit more, shall we?

A TWA app basically turns a website (or web app, if you want to split hairs) into a native app, with the help of a little UI trickery.

You could think of a TWA app as a browser in disguise. It’s an Android app without any internals, except for a web browser. The TWA app is pointed to a specific web URL, and whenever the app is booted, rather than doing normal native app stuff, it just loads that website instead  —  full-screen, with no browser controls, effectively making the website look and behave as though it were a full-fledged native app.

TWA requirements

It’s easy to see the appeal of wrapping up a website in a native app. However, not just any old site or URL qualifies; in order to launch your web site/app as a TWA native app, you’ll need to check the following boxes:

  • Your site/app must be a PWA. Google offers a validation check as part of Lighthouse, or you can check with Bubblewrap (more on that in a bit).
  • You must generate the app bundle/APK yourself; it’s not quite as easy as just submitting the URL of your progressive web app and having all the work done for you. (Don’t worry; we’ll cover a way to do this even if you know nothing about native app development.)
  • You must have a matching secure key, both in the Android app and uploaded to your web app at a specific URL.

That last point is where the “trusted” part comes in; a TWA app will check its own key, then verify that the key on your web app matches it, to ensure it’s loading the right site (presumably, to prevent malicious hijacking of app URLs). If the key doesn’t match or isn’t found, the app will still work, but the TWA functionality will be gone; it will just load the web site in a plain browser, chrome and all. So the key is extremely important to the experience of the app. (You could say it’s a key part. Sorry not sorry.)

Advantages and drawbacks of building a TWA app

The main advantage of a TWA app is that it doesn’t require you to change your code at all — no framework or platform to learn; you’re just building a website/web app like normal, and once you’ve got that done, you’ve basically got the app code done, too.

The main drawback, however, is that (despite helping to usher in the modern age of the web and JavaScript), Apple is not in favor of TWA apps; you can’t list them in the Apple App store. Only Google Play.

This may sound like a deal-breaker, but bear a few things in mind:

  • Remember, to list your app in the first place, it needs to be a PWA — which means it’s installable by default. Users on any platform can still add it to their device’s home screen from the browser. It doesn’t need to be in the Apple App Store to be installed on Apple devices (though it certainly misses out on the discoverability). So you could still build a marketing landing page into your app and prompt users to install it from there.
  • There’s also nothing to prevent you from developing a native iOS app using a completely different strategy. Even if you wanted both iOS and Android apps, as long as a web app is also part of the plan, having a TWA effectively cuts out half of that work.
  • Finally, while iOS has about a 50% market share in predominantly English-speaking countries and Japan, Android has well over 90% of the rest of the world. So, depending on your audience, missing out on the App Store may not be as impactful as you might think.

How to generate the Android App APK

At this point you might be saying, this TWA business sounds all well and good, but how do I actually take my site/app and shove it into an Android app?

The answer comes in the form of a lovely little CLI tool called Bubblewrap.

You can think of Bubblewrap as a tool that takes some input and options from you, and generates an Android app (specifically, an APK, one of the file formats allowed by the Google Play Store) out of the input.

Installing Bubblewrap is a little tricky, and while using it is not quite plug-and-play, it’s definitely far more within reach for an average front-end dev than any other comparable options that I found. The README file on Bubblewrap’s NPM page goes into the details, but as a brief overview:

Install Bubblewrap by running npm i -g @bubblewrap/cli (I’m assuming here you’re familiar with NPM and installing packages from it via the command line). That will allow you to use Bubblewrap anywhere.

Once it’s installed, you’ll run:

bubblewrap init --manifest https://your-webapp-domain/manifest.json

Note: the manifest.json file is required of all PWAs, and Bubblewrap needs the URL to that file, not just your app. Also be warned: depending on how your manifest file is generated, its name may be unique to each build. (Nuxt’s PWA module appends a unique UUID to the file name, for example.)

Also note that by default, Bubblewrap will validate that your web app is a valid PWA as part of this process. For some reason, when I was going through this process, the check kept coming back negative, despite Lighthouse confirming that it was in fact a fully functional progressive web app. Fortunately, Bubblewrap allows you to skip this check with the --skipPwaValidation flag.

If this is your first time using Bubblewrap, it will then ask if you want it to install the Java Development Kit (JDK) and Android Software Development Kit (SDK) for you. These two are the behind-the-scenes utilities required to generate an Android app. If you’re not sure, hit “Y” for yes.

Note: Bubblewrap expects these two development kits to exist in very specific locations, and won’t work properly if they’re not there. You can run bubblewrap doctor to verify, or see the full Bubblewrap CLI README.

After everything’s installed — assuming it finds your manifest.json file at the provided URL — Bubblewrap will ask some questions about your app.

Many of the questions are either preference (like your app’s main color) or just confirming basic details (like the domain and entry point for the app), and most will be pre-filled from your site’s manifest file.

Other questions that may already be pre-filled by your manifest include where to find your app’s various icons (to use as the home screen icon, status bar icon, etc.), what color the splash screen should be while the app is opening, and the app’s screen orientation, in case you want to force portrait or landscape. Bubblewrap will also ask if you want to request permission for your user’s geolocation, and whether you’re opting into Play Billing.

However, there are a few important questions that may be a little confusing, so let’s cover those here:

  • Application ID: This appears to be a Java convention, but each app needs a unique ID string that’s generally 2–3 dot-separated sections (e.g., collinsworth.quina.app). It doesn’t actually matter what this is; it’s not functional, it’s just convention. The only important thing is that you remember it, and that it’s unique. But do note that this will become part of your app’s unique Google Play Store URL. (For this reason, you cannot upload a new bundle with a previously used App ID, so make sure you’re happy with your ID.)
  • Starting version: This doesn’t matter at the moment, but the Play Store will require you to increment the version as you upload new bundles, and you cannot upload the same version twice. So I’d recommend starting at 0 or 1.
  • Display mode: There are actually a few ways that TWA apps can display your site. Here, you most likely want to choose either standalone (full-screen, but with the native status bar at the top), or fullscreen (no status bar). I personally chose the default standalone option, as I didn’t see any reason to hide the user’s status bar in-app, but you might choose differently depending on what your app does.

The signing key

The final piece of the puzzle is the signing key. This is the most important part. This key is what connects your progressive web app to this Android app. If the key the app is expecting doesn’t match what’s found in your PWA, again: your app will still work, but it will not look like a native app when the user opens it; it’ll just be a normal browser window.

There are two approaches here that are a little too complex to go into in detail, but I’ll try to give some pointers:

  1. Generate your own keystore. You can have Bubblewrap do this, or use a CLI tool called keytool (appropriately enough), but either way: be very careful. You need to explicitly track the exact name and passwords for your keystores, and since you’re creating both on the command line, you need to be extremely careful of special characters that could mess up the whole process. (Special characters may be interpreted differently on the command line, even when input as part of a password prompt.)
  2. Allow Google to handle your keys. This honestly isn’t dramatically simpler in my experience, but it saves some of the trouble of wrangling your own signing keys by allowing you to go into the Google Play Developer console, and download a pre-generated key for your app.

Whichever option you choose, there’s in-depth documentation on app signing here (written for Android apps, but most of it is still relevant).

The part where you get the key onto your personal site is covered in this guide to verifying Android app links. To crudely summarize: Google will look for a /.well-known/assetlinks.json file at that exact path on your site. The file needs to contain your unique key hash as well as a few other details:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target" : { "namespace": "android_app", "package_name": "your.app.id",
               "sha256_cert_fingerprints": ["your:unique:hash:here"] }
}]

What you should know about listing an app

Before you get started, there are also some hurdles to be aware of on the app store side of things:

  • First and foremost, you need to sign up before you can publish to the Google Play Store. This eligibility costs a one-time $25 USD fee.
  • Once approved, know that listing an app is neither quick nor easy. It’s more tedious than difficult or technical, but Google reviews every single app and update on the store, and requires you to fill out a lot of forms and info about both yourself and your app before you can even start the review process — which itself can take many days, even if your app isn’t even public yet. (Friendly heads-up: there’s been a “we’re experiencing longer than usual review times” warning banner in the Play console dashboard for at least six months now.)
    • Among the more tedious parts: you must upload several images of your app in action before your review can even begin. These will eventually become the images shown in the store listing — and bear in mind that changing them will also kick off a new review, so come to the table prepared if you want to minimize turnaround time.
    • You also need to provide links to your app’s terms of service and privacy policy (which is the only reason my app even has them, since they’re all but pointless).
    • There are lots of things you can’t undo. For example, you can never change a free app to paid, even if it hasn’t publicly launched yet and/or has zero downloads. You also have to be strict on versioning and naming with what you upload, because Google doesn’t let you overwrite or delete your apps or uploaded bundles, and doesn’t always let you revert other settings in the dashboard, either. If you have a “just jump in and work out the kinks later” approach (like me), you may find yourself starting over from scratch at least once or twice.
  • With a few exceptions, Google has extremely restrictive policies about collecting payments in an app. When I was building, it was charging a 30% fee on all transactions (they’ve since conditionally lowered that to 15% — better, but still five times more than most other payment providers would charge). Google also forces developers (with a few exceptions) to use its own native payment platform; no opting for Square, Stripe, PayPal, etc. in-app.
    • Fun fact: this policy had been announced but wasn’t in effect yet while I was trying to release Quina, and it still got flagged by the reviewer for being in violation. So they definitely take this policy very seriously.

Monetization, unlockables, and getting around Google

While my goal with Quina was mostly personal — challenge myself, prove I could, and learn more about the Vue ecosystem in a complex real-world app — I had also hoped as a secondary goal that my work might be able to make a little money on the side for me and my family.

Not a lot. I never had illusions of building the next Candy Crush (nor the ethical void required to engineer an addiction-fueled micro-transaction machine). But since I had poured hundreds of hours of my time and energy into the game, I had hoped that maybe I could make something in return, even if it was just a little beer money.

Initially, I didn’t love the idea of trying to sell the app or lock its content, so I decided to add a simple “would you care to support Quina if you like it?” prompt after every so many games, and make some of the content unlockable specifically for supporters. (Word sets are limited in size by default, and some game settings are initially locked as well.) The prompt to support Quina can be permanently dismissed (I’m not a monster), and any donation unlocks everything; no tiered access or benefits.

This was all fairly straightforward to implement thanks to Stripe, even without a server; it’s all completely client-side. I just import a bit of JavaScript on the /support page, using Nuxt’s handy head function (which adds items to the <head> element specifically on the given page):

// pages/support.vue
head() {
  return {
    script: [
      {
        hid: 'stripe',
        src: 'https://js.stripe.com/v3',
        defer: true,
        callback: () => {
          // Adds all Stripe methods like redirectToCheckout to page component
          this.stripe = Stripe('your_stripe_id')
        },
      },
    ],
  }
},

With that bit in place (along with a sprinkle of templating and logic), users can choose their donation amount — set up as products on the Stripe side — and be redirected to Stripe to complete payment, then returned when finished. For each tier, the return redirect URL is slightly different via query parameters. Vue Router parses the URL to adjust the user’s stored donation history, and unlock features accordingly.

You might wonder why I’m revealing all of this, since it exposes the system as fairly easy to reverse-engineer. The answer is: I don’t care. In fact, I added a free tier myself, so you don’t even have to go to the trouble. I decided that if somebody really wanted the unlockables but couldn’t or wouldn’t pay for whatever reason, that’s fine. Maybe they live in a situation where $3 is a lot of money. Maybe they gave on one device already. Maybe they’ll do something else nice instead. But honestly, even if their intentions aren’t good: so what?

I appreciate support, but this isn’t my living, and I’m not trying to build a dopamine tollbooth. Besides, I’m not personally comfortable with the ethical implications of using a stack of totally open-source and/or free software (not to mention the accompanying mountain of documentation, blog posts, and Stack Overflow answers written about all of it) to build a closed garden for personal profit.

So, if you like Quina and can support it: sincerely, thank you. That means a ton to me. I love to see my work being enjoyed. But if not: that’s cool. If you want the “free” option, it’s there for you.

Anyway, this whole plan hit a snag when I learned about Google Play’s new monetization policy, effective this year. You can read it yourself, but to summarize: if you make money through a Google Play app and you’re not a nonprofit, you gotta go through Google Pay and pay a hefty fee — you are not allowed to use any other payment provider.

This meant I couldn’t even list the app; it would be blocked just for having a “support” page with payments that don’t go through Google. (I suppose I probably could have gotten around this by registering a nonprofit, but that seemed like the wrong way to go about it, on a number of levels.)

My eventual solution was to charge for the app itself on Google Play, by listing it for $2.99 (rather than my previously planned price of “free”), and simply altering the app experience for Android users accordingly.

Customizing the app experience for Google Play

Fortunately enough, Android apps send a custom header with the app’s unique ID when requesting a website. Using this header, it was easy enough to differentiate the app’s experience on the web and in the actual Android app.

For each request, the app checks for the Android ID; if present, the app sets a Vuex state boolean called isAndroid to true. This state cascades throughout the app, working to trigger various conditionals to do things like hide and show various FAQ questions, and (most importantly) to hide the support page in the nav menu. It also unlocks all content by default (since the user’s already “donated” on Android, by purchasing). I even went so far as to make simple <WebOnly> and <AndroidOnly> Vue wrapper components to wrap content only meant for one of the two. (Obviously, users on Android who can’t visit the support page shouldn’t see FAQs on the topic, as an example.)

<!-- /src/components/AndroidOnly.vue -->
<template>
  <div v-if="isAndroid">
    <slot />
  </div>
</template>

<script>
export default {
  computed: {
    isAndroid() {
      return this.$store.state.isAndroid
    },
  },
}
</script>

Accounting for accounts

For a time while building Quina, I had Firebase set up for logins and storing user data. I really liked the idea of allowing users to play on all their devices and track their stats everywhere, rather than have a separate history on each device/browser.

In the end, however, I scrapped that idea, for a few reasons. One was complexity; it’s not easy maintaining a secure accounts system and database, even with a nice system like Firebase, and that kind of overhead isn’t something I took lightly. But mainly: the decision boiled down to security and simplicity.

At the end of the day, I didn’t want to be responsible for users’ data. Their privacy and security is guaranteed by using localStorage, at the small cost of portability. I hope players don’t mind the possibility of losing their stats from time to time if it means they have no login or data to worry about. (And hey, it also gives them a chance to earn those awards all over again.)

Plus, it just feels nice. I get to honestly say there’s no way my app can possibly compromise your security or data because it knows literally nothing about you. And also, I don’t need to worry about compliance or cookie warnings or anything like that, either.

Wrapping up

Building Quina was my most ambitious project to date, and I had as much fun designing and engineering it as I have seeing players enjoy it.

I hope this journey has been helpful for you! While getting a web app listed in the Google Play Store has a lot of steps and potential pitfalls, it’s definitely within reach for a front-end developer. I hope you take this story as inspiration, and if you do, I’m excited to see what you build with your newfound knowledge.