Building a Pokémon fangame PWA: What I’ve learned and how you can play it

Nick Felker
17 min readOct 26, 2023

--

Today I’m at connect.tech in Atlanta, Georgia to talk about a Pokémon fangame I’ve been slowly building over the last five years. It’s a game that’s constantly evolving and I’ve learned a lot from it.

Playing Pokémon

In this game, much like the main series, there a bunch of gameplay mechanics that they can unlock. Players are able to catch, trade, and battle these mysterious creatures known as Pokémon.

Welcome to the world of Pokémon

This has been built as a progressive web app, using Angular for the frontend and Firebase on the backend. It’s a portable system which works on workstations, laptops, and even phones. As a PWA, you have the ability to ‘install’ it to your machine so that it looks and behaves like a native app.

You can install it and it will appear in its own window

How does this game work? Let’s walk through an example.

In the Bag, you can see that I have several kinds of PokéBalls. I’ve got 4 Poké Balls. I can use each one to catch a Pokémon. They also serve as the primary currency throughout the game.

On the Encounters page I can see there are Pokémon in the Tall Grass. I can select a Poké Ball and throw it.

Wow I caught a Bellsprout!

I can see all of my Pokémon in a grid, with the ability to run a variety of filters.

If I click on my Bellsprout, a dialog will open up with a lot of key information like its type, stats, moves, nature, and more.

My PokéDex page shows all of the Pokémon I’ve caught, and which I’m missing. I’m still far from catching them all!

To make this process faster I can visit the Global Trade System. Here, players will post trades and others can complete the trades asynchronously. There’s also peer-to-peer trading if you want to play with friends.

Trade evolutions work. If you send a Machoke, a Machamp will appear on the other side. You can also have Pokémon hold items.

For rare, legendary Pokémon and important key items, players can complete a variety of multi-step quests with something as a reward.

In addition, players can also view a variety of achievements. None of the achievements particularly matter, but are something else that players can work towards.

There are also capture research tasks, where players can work towards these tasks and achieve various rewards.

Battles

I hesitated at first to implement a battle system, but I finally decided to build it. It’s evolved a lot over the past few years, going from simple 1:1s to multiple Pokémon on the field with teammates in the back.

These battles happen autonomously, with each Pokémon using the best move possible based on their natue until one side is defeated. I’ve added mechanics including hold items, weather, terrains, status condition, field conditions, stat changes, and more. I’m happy that I’ve managed to integrate almost every move in the canon, including ones like Z-Moves and Max Moves.

Players compete in various cups, with each inspired by different games. The winner of a battle receives a prize.

Battles are setup as a number of interfaces for each subcomponent which have events that are called throughout the match. Let’s take a look at the Move interface:

export interface Move {
name: string
type: Type
power: number
accuracy: number
criticalHit: number
attackKey: 'attack' | 'spAttack' | 'defense'
defenseKey: 'defense' | 'spDefense'
failed?: boolean
sound?: boolean
priority?: number
contact?: boolean
recovery?: boolean
aoe: AreaOfEffect
flavor: string
hide?: boolean
isZMove?: boolean
onGetType?: (caster: Pokemon, field: Field, move: Move) => Type
onBeforeMove?: (input: MoveInput) => Log
onAfterMove?: (input: MoveInput) => Log
onAfterMoveOnce?: (input: MoveInput) => Log
onMiss?: (input: MoveInput) => Log
zMoveFx?: ZMoveStatus
}

You can see a few required fields, a few optional ones, and a handful of different optional triggers. These take advantage of TypeScript’s pass-by-reference in order to make modifications to objects which are used for further actions.

Also, most of these events return a Log object. This is a class which is used to contain all of the messages related to the match in order. This can be flattened into an entire log of the battle which players can read while also being a flexible approach to generating a high-level view of the fight.

export function attack(params: AttackParams): Log {
const {caster, move, field, prefix, casters, targets} = params
const log = new Log()
if (casterItem && !caster.heldItemConsumed && !field.magicRoom) {
log.push(casterItem.onCasterMove?.(moveInput, caster.heldItemConsumed))
}

log.add(`${prefix} ${caster.species} used ${move.name}`)
const netAccuracy = move.accuracy *
statBuff(caster.statBuffs.accuracy) /
statBuff(target.statBuffs.evasiveness)
const hitRoll = Math.random()
const {crit} = log.push(criticalHit(caster, move)) as CriticalHit
targetMovePower *= 1.5 * Math.max(statBuff(target.statBuffs[defenseKey]), 1)
const dmg = atkStat * mult * stab * targetMovePower * dmgRange / defStat * 50
log.push(logDamage(target, adjustedDmg))
log.push(move.onAfterMoveOnce?.(moveInput))
log.push(move.onAfterMove?.(moveInput))
log.push(casterItem.onAfterCasterMove(moveInput, caster.heldItemConsumed, adjustedDmg))
log.push(field.terrain.onAfterCasterMove?.(caster, target, move))
if (target.conditions) {
target.conditions.forEach(condition => {
log.push(condition.onAfterTargetMove?.(moveInput))
})
}
return log
}

When the same Log class is also used for status conditions, field, hold items, and more, it becomes a flexible way to keep track of what’s happening.

Raid Battles

Players could trade and battle, but generally the game was mainly solitary. That changed in March 2020 when I introduced raid battles. These are large multiplayer player-versus-environment matches against strong bosses. They are designed intentionally so that one person cannot complete them on their own. It requires them to work together. Victors can catch the raid boss and get a bunch of rewards.

To make it more challenging, I introduced variants. These are the same Pokémon but with distinct movesets. It gives players many more Pokémon to collect.

There are many more features that players can explore including farming, lottery tickets, radio quizzes, and more.

Developing a Pokémon Game

All Pokémon species are defined internally by a string with hyphenated parameters which includes their ID. Hyphens can also include a form, a gender, a shiny state, or multiple together.

These BadgeIds are used to grab data from a large dictionary mapping the ID to its data.

{
"potw-001": {
species: "Bulbasaur",
type1: "Grass", type2: "Poison",
move: ["Razor Leaf", "Sludge Bomb"],
moveTMs: [
'Solar Beam', 'Snore', ...
],
hp: 45, attack: 49, defense: 49,
spAttack: 65, spDefense: 65, speed: 45,
tiers: ['Tiny Cup', 'Crown Cup'],
pokedex: "A strange seed was planted on its back at birth",
},
...
}

The dictionary contains lookup data for the bare species, but also needs to factor in edge cases like Burmy, whose gender and forms can affect its in-game data.

{
'potw-412': {
species: 'Burmy', gender: ['male', 'female'],
needForm: true, syncableForms: ['plant', 'trash', 'sandy'],
type1: 'Bug',
tiers: ['Tiny Cup'],
pokedex: `To shelter itself from cold, wintry winds...`,
hp: 40, attack: 29, defense: 45,
spAttack: 29, spDefense: 45, speed: 36,
move: ['Bug Bite'],
moveTMs: [
'Snore', 'Electroweb', 'String Shot', 'Protect',
],
},
'potw-412-male': {
// ...
levelAt: 20, levelTo: 'potw-414',
},
'potw-412-plant-female': {
// ...
levelAt: 20, levelTo: 'potw-413-plant',
},
}

When you catch a Pokémon, a PokemonId will be created which includes individual creature data.

There’s four bytes of data which optimally fit in personality data.

| BYTE 1                   | BYTE 2                                                  | BYTE 3   | BYTE 4       |
| Nature(3) | PokeBall (5) | Variant (4) | Gender (2) | Shiny (1) | Affectionate (1) | Form (8) | Location (8) |
| Timid: 3 | GreatBall: 1 | Var1: 1 | None: 0 | False: 0 | False: 0 | None: 255| Atlanta: 101 |

For a Bulbasaur with the specific criteria above, it has the four bytes of 97, 8, 255, and 101 which can be parsed by our system. It is then encoded using a 64-bit scheme to come up with a PokemonId of 1#1x4fZB .

This ID is stored in user data as a map of ID with number. The user data interface also contains other data which is all stored in Firebase Cloud Firestore.

interface Users.Doc {
pokemon: Partial<Record<PokemonId, number>>
items: Partial<Record<ItemId, number>>
location: LocationId
berryPlanted?: (BerryPlot | undefined)[]
voyagesCompleted?: number
evolutionCount?: number
restorationCount?: number
formChangeCount?: number
settings: Record<Settings, boolean|string>
...
}

Firebase

I use Firebase’s features for all serving needs. It provides me with hosting, cloud functions, a database, and authentication.

Cloud functions provide a serverless way to run code, written in TypeScript, with easy authorization and database integrations. As both my frontend and backend are written in TypeScript, I can reuse a lot of data and functions between them.

Most of my written functions are callable directly from the web app, but a few are cron tasks which run periodically.

Day Care feature

Let’s take a quick look at how the Day Care feature is written. First I have defined a shared interface for the request and response types.

export namespace Daycare {
export interface Req {
species: PokemonId[]
heldItem: ItemId[],
isPrivate: boolean
}

export interface Res {
egg?: {
/** day-care.ts -> EggDoc */
hatch: number
species: s
}
evolution?: any
evolved?: boolean
/** Parents IDs (evolved) and Shedinja sometimes */
parents: PokemonId[]
}
}

In my frontend, I can add a send function which will gather UI data and send a backend request to my cloud function. Note how I can use the function request and response for additional type-safety.

async send() {
this.exec.send = true
window.requestAnimationFrame(async () => {
try {
const res = await this.firebase.exec<F.Daycare.Req, F.Daycare.Res>('daycare', {
species: this._selection,
isPrivate: this.isPrivate,
heldItem: this.items._selection ?
this.items._selection.map(x => x.item) : undefined,
})
this.res = res.data
this.firebase.refreshUser()
} catch (e: any) {
this.snackbar.open(e.message, '', {
duration: 5000
})
} finally {
this.pokemon!.clearSelection()
this.exec.send = false
}
})
}

My backend is able to also use these types while also taking advantage of the pre-defined authentication from Firebase. I can easily get and update data from Cloud Firestore.

export const daycare = functions.https.onCall(async (data: F.Daycare.Req, context): Promise<F.Daycare.Res> => {
if (!context.auth) {
throw new functions.https.HttpsError('not-found', '')
}
const userId = context.auth!.uid;
const species = data.species.map(x => new Badge(x))

// Do a bunch of work...

const egg = await db.runTransaction(async transaction => {
const user = await transaction.get(userRef)
let {eggs} = user.data()!
const egg: EggDoc = {
hatch: Date.now() / 1000 + WEEK_S,
species: eggSpecies
}
eggs.push(egg)
transaction.update(userRef, { eggs, items, eggsLaid: FieldValue.increment(1) })
return egg
})

return { egg, parents }
})

All user data can be stored into a single document within Cloud Firestore, which can use the userId as a primary key. This document has a 1MB limit, though otherwise there are very generous limits to how many documents can be in a collection and how nested a document can be.

Angular

The web frontend is written in Angular, which is a reliable framework. Additionally I’ve adopted a lot of native web browser features for great progressive web apps.

PWA Manifest

A manifest.json file is the central definition of a PWA which outlines what your PWA is and what it can do. This metadata is used to create all the native operating system hooks.

{
"id": "pokemon-of-the-week",
"start_url": "/",
"name": "Pokémon of the Week",
"short_name": "PotW",
"description": "Collect and discover Pokémon",
"icons": [
{
"src": "images/null144.png",
"sizes": "144x144",
"type": "image/png"
}
],
"theme_color": "#3f51b5",
"background_color": "#3f51b5",
"screenshots": [{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "339x731"
}, {
"src": "/images/screenshot2.png",
"type": "image/png",
"sizes": "337x729"
}],
}

One additional feature is the ability to define file handlers. If I have a rare Pokemon I want to distribute, such as this Fancy form Vivillon, I can create a fancy.pkmn file and hand it out. When players click on the file, they can use my web app.

{
// ...
"file_handlers": [{
"action": "/dowsing",
"accept": {
"text/plain": [".pkmn"],
},
}]
}

My dowsing page will open and I can check the launchQueue and read in any files that may have been included in the launch action.

if ('launchQueue' in window && 'files' in window.LaunchParams.prototype) {
window.launchQueue.setConsumer(async (params) => {
for (const handle of params.files) {
const file = await handle.getFile()
const rawText = await file.text()
const {id} = JSON.parse(rawText)
this.dowse(id)
}
})
}

For activities like raids and private trades, I can define custom web protocols to make links easier to share. Rather than defining a long URL, my manifest can define a shorter web-raid:vSz9HSyPoZxn in orde to quickly launch to the raids page with that ID.

{
//...
"protocol_handlers": [{
"protocol": "web+raid",
"url": "/raids?%s"
}, {
"protocol": "web+trade",
"url": "/trade?%s"
}],
}

I am also able to collapse the title bar so that my top navbar becomes part of the window chrome, which saves space and makes the game look much more native.

It’s quite easy to add this in my manifest as well:

{
// ...
"display": "standalone",
"display_override":
["window-controls-overlay"],
}

Dialogs

I can use the browser’s native dialog element to display content in a top layer above my normal page UI. This allows me to focus the user’s attention without having to mess around with CSS z-indexes.

<dialog>
<typed-dialog-header [type]="pokemon.type1">
{{pokemon.species}}
</typed-dialog-header>
<h2 *ngIf="badgeForm">
{{badgeForm}} form
</h2>
<type-box [type]="pokemon.type1"></type-box>
<type-box [type]="pokemon.type2"></type-box>

<small>
#{{natDexNo}}
<span *ngIf="badge.personality.gender">
{{badge.personality.gender}}
</span>
</small>
<img class="main-sprite" src="{{sprite}}" />
</dialog>

This also includes a backdrop layer that I can use to style everything which isn’t the dialog. I can easily add a darkened background and apply a blur to better focus on my dialog.

dialog[open]::backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}

Gyroscope

To evolve Inkay into Malamar in the main series, you have to hold your game console upside down. You can do that here as well with the browser’s sensor APIs. Once you connect to it, you can get gyroscopic data in all dimensions.

if (!('Gyroscope' in window)) {
console.warn('No gyroscope detected')
} else {
this.gyroscope =
new window.Gyroscope({frequency: 5})
this.gyroscope.start()
}

// ...

const res = await
this.firebase.exec<F.UseItem.Req, F.UseItem.Res>('use_item', {
item: itemId,
target: this.pokemon._selection[0].species,
hours: new Date().getHours(),
gyroZ: this.gyroscope ? this.gyroscope.z : 0,
})

Web Animations

Subtle animations are scattered throughout the game, which add a bit of joy and polish.

These animations are fairly easy to implement. I also take consideration of accessibility by disabling animations when the prefers-reduced-motion media query is enabled.

sprite-pokemon.animate {
animation: jump 0.4s 2 linear;
}

@media (prefers-reduced-motion) {
sprite-pokemon.animate {
animation: none;
}
}

@keyframes jump {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-15px);
}
100% {
transform: translateY(0px);
}
}

Another accessibility media query to support is forced-colors , which changes colors on the page to be high contrast. Shadows are no longer available, so you need to define edges only using borders.

/** In forced-colors mode, there are no shadows. */
@media (forced-colors: active) {
button[mat-button] {
border: solid 2px var(--button-text-color);
}
button[mat-button]:hover {
border-color: var(--type-fire);
text-decoration: underline;
}
button[mat-button]:focus {
border-color: var(--type-electric);
text-decoration: underline;
}

mat-icon:hover, mat-icon:focus {
color: var(--type-fire);
text-decoration: underline;
}
}

Color Palettes

Selecting colors is an important part of branding and it’s quite easy to do this by using CSS variables. I can pick a color for each type in the franchise and define it once. Then I can easily use that color elsewhere in the app without needing to copy hex values around.

--type-bug: #7CB342;
--type-grass: #4CAF50;
--type-fire: #f44336;
--type-flying: #607B8B;
--type-ghost: #673AB7;
--type-ice: #00BCD4;
--type-water: #2196f3;
--type-fighting: #795548;
--type-rock: #3E2723;
--type-electric: #FF9800;
--type-fairy: #E91E63;
--type-psychic: #9C27B0;
--type-dark: #424242;
--type-dragon: #3F51B5;
--type-ground: #5D4037;
--type-normal: #546e7a;
--type-poison: #7B1FA2;
--type-steel: #616161;

This works even better when I implemented a dark theme. I can update some of these colors only while the dark mode is active so that I don’t need to update all of my components.

body.dark {
color: #efefef;
--app-background-color: #424242;
--card-background-color: #212121;
--h1-color: #ccc;
--app-text-color: #bbb;
--menu-text-color: #ccc;
--toolbar-color: #212121;
--link-color: #09f;
--button-bg-secondary: #212121;

--type-fighting: #a1887f;
--type-poison: #EA80FC;
--type-rock: #A1887F;
--type-normal: #FAFAFA;
--type-dark: #BDBDBD;
--type-psychic: #E040FB;
--type-ghost: #9575CD;
--type-dragon: #7986CB;
--type-ground: #8D6E63;
--type-steel: #9e9e9e;
}

In-Game Map

This location popup is created using Leaflet JS. There are about 160 locations available in the game, each with a preset region, terrain, and other metadata. Weather changes every day depending on the specific location and season.

Advanced Filtering

I created a package fleker/cel-js which implements the Common Expression Language in the browser, making it possible to filter your Pokémon using a wide range of fields and datatypes.

Notifications

In-game notifications can be integrated into browser notifications by using Firebase Cloud Messaging. You can even receive these notifications on your phone, keeping players engaged on whichever platform they are using.

Handling Bugs

Over the past five years, I’ve had a number of serious bugs which have required quick intervention.

Raid Bosses

Take for example this simple array, which defines raid bosses:

const raidBosses = [{
species: 'potw-026' // Raichu
}, {
species: 'potw-112' // Rhydon
}, {
species: 'powt-116' // Tangela
}]

There is a typo right there where I use the wrong key for Tangela. That causes pages to crash and raids to fail. Having to deal with these typos isn’t ideal, so I sought out a fix. I ended up creating a package called fleker/gents which I use to generate TypeScript files to improve consistency where I couldn’t otherwise.

One file is type-pokemon , which turns each Pokémon into a constant that I can call directly. This also saves me from using the wrong Pokémon number in the future.

import * as P from './type-pokemon'

const raidBosses = [{
species: P.Raichu,
}, {
species: P.Rhydon
}, {
species: P.Tangela
}]

Database Typing

Take this other example, which adds a Pokémon to the player’s collection:

const ref = 
db.collection('users').doc(userId)
const doc = await ref.get()
const user = doc.data()

users.pokemon[pokemonId]++

await ref.set({ pokemon: users.pokemon })

As I’m calling set rather than update , all of that user’s data has just been deleted. Thankfully I have a cronjob which just backs up user data.

Going forward I created a package called fleker/salamander which adds generics to Cloud Firestore APIs. This means that the TypeScript compiler will now fail if I try to call set with incorrect data, including missing fields.

import { Users } from './server-types'
const db = salamander(firestoreDb)

const ref =
db.collection('users').doc(userId)
const doc = await ref.get<Users.Doc>()
const user = doc.data()

users.pokemon[pokemonId]++

await ref.update<Users.Doc>({ pokemon: users.pokemon })

Displaying Sprites

Now I need to display sprites of a given Pokémon on the page. To do that, I have created a sprite-pokemon component.

<sprite-pokemon [badge]="A#Yf_4"></sprite-pokemon>

The behavior of the component is defined as:

// Component onload
ngOnChanges(changes: SimpleChanges) {
const newv =
changes['badge'].currentValue
const pkmnBadge = new Badge(newv)
this.src =
pkmn(pkmnBadge.toSprite())
this.alt = pkmnBadge.toLabel()
}

// badge.toSprite()
const sprite = ...
return `/images/sprites/pokemon/${sprite}.png`

What’s the issue here?

I forgot to upload that sprite, so it just reports a 404.

Over the last five years I have added a number of unit tests which basically check and verify certain assumptions, like that all of the sprites exist that I think should exist.

test('Check all Pkmn', t => {
const pkmn = Object.entries(datastore)
let checkFail = false
pkmn.forEach(([key, p]) => {
const badge = new TeamsBadge(key as BadgeId)
const sprite = S.pkmn(key as BadgeId)
badge.shiny = true
const sprite2 = S.pkmn(badge.toSprite())
if (!fs.existsSync(`../${sprite}`)) {
checkFail = true
t.log(`Cannot find sprite ${sprite}`)
}
if (!fs.existsSync(`../${sprite2}`)) {
checkFail = true
t.log(`Cannot find sprite ${sprite2}`)
}
})
t.false(checkFail)
})

Up to this point I’ve only had var1, var2, var3, and var4 Pokémon. But I’ve added a bug bounty program to help catch these kinds of issues before they spread and get exploited. Those who report an issue receive a shiny var0 of their choice, which is otherwise not possible to obtain.

potw-212-shiny-var0

Games as a Service

When you host a game as a service, you need to find ways to keep players engaged over time. There are many in-game events which run annually, such as National Lobster Day, which give players specific benefits for one day only.

Other features like raid bosses and mass outbreaks regularly rotate every few weeks, creating fresh content which is relatively easy to update.

I also try to provide opportunities for players to choose the content, such as a voting system for mass outbreaks and encourage them to file feature requests. These can give the player base a sense of control and shared destiny.

Straw polls are another mechanism to help me quickly gauge player interests in upcoming changes.

When a feature is still in-progress, I will add it as a feature flag that players can turn on and off in order to collect early feedback.

Managing the game economy is also something I continue to re-examine. For every game you have newbies with very thin wallets and whales who have five years of wealth. They have different gameplay styles and gameplay needs, and I try to find ways to balance those.

Maintaining services

Google Cloud lets you setup an email address where it will send error messages. When the backend crashes I get a message about it, where I then need to triage and prioritize a fix.

Chatbot

As this game has continued to develop, I have received a number of emails from players with assorted questions. I have taken advantage of a PaLM extension in Firebase to build a chatbot which is supposed to resemble Professor Oak. He even writes poems!

Yet it is using PaLMs broader understanding of the world pulled from data online rather than from my game data and my dictionaries. I think the latter would provide far more value to the players.

I will have to do some more research and experimentation with retrieval augmented generation, in which I can process the user query and match it to one of my data sources. Then I can use my own data and create a persona of Professor Oak in which to reply with the answer.

Pokémon as a Service

This is not just a game I made, but a platform where you can build and host your own Pokémon game that is exclusive to your community: your workplace, your college, or your Discord group.

There is a demo available for you to run: https://pokemon-as-a-service.web.app/

The demo has a limit of catching 250 Pokémon.

Instead, you can download the source code from GitHub and build it yourself. All instructions to do that are available in the README.

https://github.com/fleker/pokemon-as-a-service

Everything you need is in there. The file structure is broken out into separate directories. Game administrators can focus on changing the files in src/platform .

I hope you catch them all!

--

--

Nick Felker

Social Media Expert -- Rowan University 2017 -- IoT & Assistant @ Google