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.
Table of contents
- What’s the game (and what’s that name)?
- Choosing Nuxt
- Achieving native app feel with the web
- Vibration and sound
- Gameplay, history, and awards
- Pros and cons of this approach
- Logistics: turning a web app into a native app
- What is a TWA app?
- How to generate the Android App APK
- The signing key
- What you should know about listing an app
- Monetization, unlockables, and getting around Google
- Customizing the app experience for Google Play
- Accounting for accounts
- Wrapping up
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.
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.
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.
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.
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:
- 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).
- You can choose whether to receive a hint at the start of each game, and if so, how much that hint reveals.
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.)
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.
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):
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:
- 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;
- 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), orfullscreen
(no status bar). I personally chose the defaultstandalone
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:
- 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.) - 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.
This post is gold!
Thank you! really nice information, I really want to try it with react
Very interesting to hear how you accomplished this with Vue! I recently did exactly this with my React app Crab Fit, although to get around the donation iAP issue, I’m using the digital goods API to perform an in app purchase from the pwa. Might be something to think about if you wanted to keep your app free, but it also requires Chrome at the moment, and you need to sign up for the origin trial.
How much money such a app can make a month or a year ?
Thank you for this great post.
Hey, Chris—that’s a pretty tough question to answer, since it will depend entirely on how popular the app is. I’m sure some apps on Google Play make their creators a living, and others make little to nothing.
I believe a sufficiently talented and motivated individual could definitely make a worthwhile amount of money off a good app, marketed well to the right audience.
In my specific case, however, I wasn’t really optimizing for that. What I’ve made off Quina could better be described as “beer money” than “rent money.” If money were the goal, I would’ve been better off in my case just freelancing with those hours. But everybody and every app will be different, and it was worthwhile to me just for the experience.
Any reason why it’s not available in all countries? Wanted to test how does the app experience compare to native apps but it’s not available in my country
Hey Dominik! If I remember correctly, Google had some restrictions around releasing to all countries, and so I only listed the app in countries with above a certain percentage English-speaking population.
In retrospect, that was probably a little…is there a word for “ableist,” but in regard to languages? Presumptuous of me, at the very least.
Happy to look into adding it to any new countries people might like if you let me know what those might be, though! :) In the meantime, maybe a VPN would work?
I just passed by to say your pronunciation is right. In Brazil, Quina is also the name of a lotery game where you need to match all 5 numbers in order to win so your meaning is very close. It also means 5 stuff grouped together. It’s nice you were that far to name your game, it shows how much you care and your enthusiasm. Will probably check it out just because of it. Thanks for sharing!
Honestly, great work! Whilst my interest lies in different technologies, I read your post to the end. It’s well written and explores the topic of building an app well to the uninitiated. All with realistic expectations, pitfalls and hard decisions that need to be made.
Off to the play store I go, to try your app.
Very good read, very well laid out ^_^
You could ensure privacy and security also by enabling support for Solid as well. This way users could access their data across implementations.
Best post I’ve read in a long time. Really great article. Practical, clear & engaging. Thanks for sharing your experience.
I googled “five at a time in Latin”, and it seems to be “quini”?
quini on Wiktionary
You’ve done a great job, honestly! However, I read the entire post even though my interest lies with other technologies. The article is well written and provides insight into app development for those who are unfamiliar with it. There are challenges, pitfalls, and decisions that have to be made in each situation.
Let’s go to the play store so I can try your app.
Hi Josh,
Your app Quina is not available in India in Googleplay. Why?
Please make it available.
I would like to test how vue/nuxt works as webview, how performant. I also do lots in vue
Thanks
Hi, Kumar. I’ve just added India to the list of countries for the app.
For what it’s worth, however: recently, Google Play has taken the app down in error several times, and if it keeps happening, I may just remove it entirely. It will always be available at quina.app, and from my personal experience, installing it as a PWA seems to work identically to having it installed as an Android app.