Edelgard: Interactive Wiki of all endings from Fire Emblem: Three Houses
Background
For a dedicated fan of JRPG games like me, the release of Fire Emblem: Three Houses greatly helped to quench my thirst for another masterpiece of this genre since I had completed 100% Xenoblade Chronicles 2 a few months ago. As a result, I have spent the last two months deeply invested in this newest installment of the legendary franchise Fire Emblem.
Similar to its predecessors, Fire Emblem: Three Houses is a turn-based strategy game where you as the protagonist will form a party of heroes to fight through multiple battles as you are progressing through the story of the game. For this new title, studio Intelligent Systems introduces 4 separate branching routes with different storylines, which requires the player to do multiple playthroughs to have a full experience of the game. Also, at the end of each playthrough, each character in the party will form a pair with another ally who has the strongest bond with them and the game will then display a slideshow of unique endings for each pair. The ending for a pair can also be different depending on the route that we are in.
After a few playthroughs where I have tried out all different routes in the game, I recognized it would take me forever to see all of these paired endings. At first, I decided to resort to the community to see if anyone had managed to share a complete collection of these endings. Unfortunately, all I found were just fragments of them which were shared by other players on platforms like Serenes Forest forum or /r/fireemblem subreddit. 😔
As a web developer at heart, I decided that this would be a good chance for me to give back to the community, where there are plenty of people carrying the same passion for this game like me. Hence, I embarked on a journey to create a website serving as the encyclopedia of all endings in this game. 🛳
Data Source
Before we even begin to develop the wiki, we first must need to find a way to create the data source for the app to refer to, which in this case is the text of all the endings. Since no one had succeeded in sharing all of the endings before, I figured that the best method is to look at the text dump from the game itself and extract the endings from there.
To be able to dump all the text from the game, the main approach is to use Nintendo Switch CFW (Custom Firmware) and its homebrew apps to extract the game files and parse them into a readable format. After a few weeks of research, I finally managed to extract the game text by using tools from the open-source community such as Atmosphere & hactool. The full-text dump result can be found in dump.txt. The fully detailed process of dumping the game files and decrypting them deserves another article about it, which I may write in the future. 🔑
For a text dump, it will contain all types of text inside the game, which are majorly unnecessary for our use case. Our first task was to filter the text dump by extracting only the part that we need. By manually scanning through this ocean of text, I was able to identify the section comprising all the endings which are in the range of lines [13360 - 13725]
. 🎉 I then extracted this section into another text file called dump-endings.txt. In dump-endings.txt
text file, each line represents an ending for a certain pair of characters among the 4 different routes comprising Crimson Flower, Azure Moon, Verdant Wind and Silver Snow. If it's an ending including the MC (Main Character), the ending text will be a template string where all the subject words referring to the protagonist will be an expression so that the game can replace the actual word depending on the chosen gender of the protagonist at the beginning of the game.
Ending between protagonist and Hilda in Verdant Wind
and Silver Snow
S0
announcedEK00hisEL00herEM00
marriage to Hilda\n
shortly after becoming leader of the United Kingdom\n
of Fódlan. As queen, Hilda rarely took part in politics\n
herself, but she nevertheless contributed greatly to the\n
restoration of Fódlan by recommending exceptional\n
people to influential positions. Her hobby of creating\n
fashionable accessories also bore fruit, as her designs\n
achieved widespread popularity, and she created the first\n
artisan academy in Fódlan. The school produced many\n
talented craftsmen, one of whom created a statue that\n
expertly captured the king's delight at receiving the gift\n
of a bracelet from the queen.
Ending between Claude and Lysithea
Entrusting the future of Fódlan to his friends, Claude left
\n
for Almyra to reclaim his place as heir to the throne.\n
When he became king, he asked Lysithea to be his queen\n
with a heart full of love, and with the hope of fostering\n
friendly diplomatic relations with Fódlan. Due to her\n
shortened lifespan, Lysithea declined. Claude, unable to\n
abandon his love for her, gave up the throne to go on a\n
quest for a means to save her. Years later, he appeared\n
suddenly before her, claiming to have found a cure.\n
With her trust and love to guide him, he whisked her\n
away, across the sea. It is unknown where they went.
From these few examples, we can deduce these points:
- In each ending text, the name of the characters in the pair will most likely appear inside. For the case of the protagonist, it will be
S0
. If only one name is found in the text, the ending will be a solo ending for that character. EK00xEL00yEM00
is similar to anif / else
expression. Depending on the gender of the MC, eitherx
ory
will be used.- If a pair has multiple endings for different routes, the text of these endings will be in consecutive lines and follow the order of routes as Crimson Flower, Azure Wind, Verdant Wind & Silver Snow.
- The text comprises a lot of escaped
\n
new line characters, which are used to notify the game to display the sub-text in a new line. For our case, we don't need these characters since we can make the paragraph wrap by itself when it overflows. - Each character has a different list of available partners they can form a pair with and this list can also be different for the male/female protagonist. Furthermore, certain pairs can only be formed in specific routes instead of being available in all 4 branching routes.
With the above points, we can easily define a basic structure of the data source for our app.
export type Character =
| "Byleth M" // Male Protagonist
| "Byleth F" // Female Protagonist
| "Edelgard"
| "Dimitri"
| "Claude"
| "Hubert"
| "Ferdinand"
| "Linhardt"
| "Caspar"
| "Bernadetta"
| "Dorothea"
| "Petra"
| "Dedue"
| "Felix"
| "Ashe"
| "Sylvain"
| "Mercedes"
| "Annette"
| "Ingrid"
| "Lorenz"
| "Raphael"
| "Ignatz"
| "Lysithea"
| "Marianne"
| "Hilda"
| "Leonie"
| "Seteth"
| "Flayn"
| "Hanneman"
| "Manuela"
| "Jeritza"
| "Gilbert"
| "Alois"
| "Catherine"
| "Shamir"
| "Cyril"
| "Rhea"
| "Sothis";
type Endings = string[];
type PairEndingsMap = {
[charA in Character]: {
[charB in Character]: Endings;
};
};
The next step is to put up a script to parse the endings text to a JSON file with the structure of PairEndingsMap
. The reason we decide to use JSON format as our data source is that it's the native format for Javascript to interact with easily.
The script will first read from the dump-endings.txt
text file, clean up the text by removing all unnecessary characters and go through it line by line.
const fs = require("fs");
const path = require("path");
const content = fs.readFileSync(
path.resolve(__dirname, "./endings.txt"),
"utf-8",
);
const lines: string[] = content
// Remove all \r characters
.replace(/\r/g, "")
// Replace all escaped new line with spaces
.replace(/\\n/g, " ")
// Replace all double spaces with spaces
.replace(/ /g, " ")
// Remove all escape characters
.replace(/\u001b/g, "")
// Split into array of strings by \n characters
.split("\n")
// Trim all strings
.map(line => line.trim())
// Filter out all empty strings
.filter(line => line.length > 0);
// Map of endings
const endingsMap: PairEndingsMap = {};
for (const line of lines) {
// ...
}
As we iterate through each line, we will first need to check which characters this line is about. Remember that S0
represents the protagonist.
const characters = ["S0", "Edelgard", "Claude", ...];
function getEndingCharacters(ending) {
let characterA, characterB;
for (const character of characters) {
if (ending.includes(character)) {
if (characterA == null) {
characterA = character;
} else {
characterB = character;
}
}
}
return [characterA, characterB];
}
// ...
const endingsMap = {};
for (const line of lines) {
const [characterA, characterB] = getEndingCharacters(ending);
if (characterB == null) {
// Solo ending
// ....
} else if (characterA === "S0") {
// Ending including MC
// ...
} else {
// Ending between two characters besides protagonist
// ...
}
}
The final step we need to do is to handle the 3 main cases of endings: solo endings, endings including MC and normal pair endings. For endings with the protagonist, we need to remember to parse the template string into 2 different endings for male / female MCs.
function addEntry(endingsMap, characterA, characterB, ending) {
if (endingsMap[characterA] == null) {
endingsMap[characterA] = {};
}
if (endingsMap[characterA][characterB] == null) {
endingsMap[characterA][characterB] = [];
}
if (endingsMap[characterB] == null) {
endingsMap[characterB] = {};
}
if (endingsMap[characterB][characterA] == null) {
endingsMap[characterB][characterA] = [];
}
endingsMap[characterA][characterB].push(ending);
endingsMap[characterB][characterA].push(ending);
}
function addSelfEntry(endingsMap, character, ending) {
if (endingsMap[character] == null) {
endingsMap[character] = {};
}
if (endingsMap[character][character] == null) {
endingsMap[character][character] = [];
}
endingsMap[character][character].push(ending);
}
function cleanUpMainCharEnding(ending, isMale) {
if (isMale) {
return ending
.replace(/S0/g, "Byleth")
.replace(/EK00/g, "")
.replace(/EL00\w+EM00/g, "");
} else {
return ending
.replace(/S0/g, "Byleth")
.replace(/EK00\w+EL00/g, "")
.replace(/EM00/g, "");
}
}
// ...
const endingsMap = {};
for (const line of lines) {
const [characterA, characterB] = getEndingCharacters(ending);
if (characterB == null) {
addSelfEntry(endingsMap, characterA, line);
} else if (characterA === "S0") {
addEntry(
endingsMap,
"Byleth M",
characterB,
cleanUpMainCharEnding(line, true),
);
addEntry(
endingsMap,
"Byleth F",
characterB,
cleanUpMainCharEnding(line, false),
);
} else {
addEntry(endingsMap, characterA, characterB, line);
}
}
fs.writeFileSync(
path.resolve(__dirname, "../json/endings.json"),
JSON.stringify(endingsMap, null, 2),
"utf-8",
);
At this stage, we have successfully created the JSON file mapping all the endings for each character to himself/ herself or other characters. 🙌 The full JSON file and parse script can be found at endings.json & gen-endings-json.js
Tech Stack
After successfully constructing the data source, the next step is to work on the web application itself! 🤩
For the past 2 years, whenever I work on a personal web application, I will usually use create-react-app to set up the core tech stack quickly so I can just focus on implementing the project's idea.
However, for this application, I decided to take up the challenge of making the production app as performant as possible, 😤 which would require me to create some custom configurations instead of the defaults from create-react-app. Furthermore, as an effort to minimize the app bundle's sizes, I would also like to use an alternative UI framework to React with a much smaller size (~ 3kB), which is Preact. Since Preact provides the same set of APIs like React, it would not take much more effort in developing the UI, which is a huge plus for me. For routing, I also used wouter - a minimally alternative library to react-router with full Hooks support and a much smaller size (~ 1.2kB).
Hence, the minimal tech stack for this application comprises:
- PreactJS
- Wouter
- Babel
- Webpack
- Typescript
- Netlify
UI Implementation
For this interactive wiki, the UI was aimed to give users the same look and feel as what they experienced in the original game while keeping the app as minimal and performant as possible. The UI should also be responsive to allow all mobile users to browse the wiki easily.
There will be 3 main pages: Home Page, Partners Page & Endings Page.
Home Page /
This is where the users first land on when they access the wiki. The users will be given the list of available characters inside the game to select as the first character in the pair to be formed.
The list of characters here is predefined as a constant by us.
Partners Page /:characterA
After selecting a character in Home page, users will be directed to the following page with nearly identical UI as the home page. The only difference here is that the list of characters will be less than the full list since this is the list of available partners for the selected character.
Since the list of characters here is different depending on which character we selected previously, we will need to write another script to generate a JSON file mapping each character to their available partners. We can easily create this JSON by referring to the data source JSON file endings.json
we created above.
Since this mapping of character's partners is not as big as the full collection of endings, we will import the generated JSON file into the JS bundle directly instead of requiring the application to fetch the JSON externally.
const fs = require("fs");
const path = require("path");
const content = fs.readFileSync(
path.resolve(__dirname, "../json/endings.json"),
"utf-8",
);
const endingsMap = JSON.parse(content);
const partnersMap = {};
for (const character of Object.keys(endingsMap)) {
partnersMap[character] = Object.keys(endingsMap[character]);
}
Endings Page /:characterA/:characterB
After selecting the partner, users will be directed to the final page where the app will display different endings for this pair of selected characters. It's important to note that not all pairs will have 4 different endings to choose from since some characters are unique and only available in specific routes.
For this final page, besides implementing the UI, we will also need to add the logic to fetch the JSON file endings.json
since this is the required data source to display the endings.
At this point, we should start to realize the problem. The endings.json
file contains all endings for every available pair of characters while what we need here are just endings for a single pair of characters. It will majorly slow down the app if we proceed to fetch such a giant file as endings.json
while we just use a small part of its data.
Therefore, the solution here is to split the endings.json
into multiple small JSON files where each file just represents the endings for a specific pair of characters. The application will then fetch the respective file based on the selected characters.
const fs = require("fs");
const path = require("path");
const content = fs.readFileSync(
path.resolve(__dirname, "../json/endings.json"),
"utf-8",
);
const endingsMap = JSON.parse(content);
for (const characterA of Object.keys(endingsMap)) {
for (const characterB of Object.keys(endingsMap[characterA])) {
if (characterA.localeCompare(characterB) <= 0) {
fs.writeFileSync(
path.resolve(
__dirname,
`../src/json/${characterA}_${characterB}.json`,
),
JSON.stringify(endingsMap[characterA][characterB]),
"utf-8",
);
}
}
}
After successfully created all these JSON files, the logic of fetching the correct JSON file given a pair of characters can be easily implemented with Hooks.
import { h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
type Props = {
characterA: Character;
characterB: Character;
};
function PageEndings(props: Props): VNode<Props> {
const [endings, setEndings] = useState<string[]>([]);
useEffect(() => {
const jsonFile =
characterA.localeCompare(characterB) <= 0
? `/${characterA}_${characterB}.json`
: `/${characterA}_${characterA}.json`;
fetch(jsonFile)
.then((response: Response) => response.json())
.then((endings: unknown) => {
if (endings instanceof Array) {
setEndings(endings);
} else {
return Promise.reject(
new Error(
"JSON response does not match expected type Array",
),
);
}
})
.catch((err: Error) => {
console.log("Page Endings", err.message);
});
}, [characterA, characterB]);
return (
...
);
}
Performance Optimisation
So at this stage, we have successfully created an interactive Wiki where users can browse all the available endings in Fire Emblem: Three Houses. 🎉
However, as mentioned above, I would also like to take this chance to challenge myself to improve the speed of the application as much as possible. This would be a good opportunity to further my knowledge in various ways to enhance web performance.
Font Loading
I have always been a fan of using System Font for web applications where users will not encounter either FOIT (Flash of Invisible Text) or FOUT (Flash of Unstyled Text) issues.
However, for this wiki, since the objective is to provide users the same look and feel as the original game, System Font does not fit very well with the overall theme. After navigating through Google Fonts for a while, I finally managed to find a font that looks almost identical to the font inside the game, which is EB Garamond.
For Google Fonts, their recommended approach to import the fonts is to put a link
HTML tag requesting for a stylesheet with the font definitions inside. For example, if I want to fetch the font above with font-weight 400
and 600
, I will need to put this <link href="https://fonts.googleapis.com/css?family=EB+Garamond:400,600" rel="stylesheet">
tag into my app's HTML. The fetched stylesheet will be in this format:
@font-face {
font-family: 'EB Garamond';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ebgaramond/v13/SlGUmQSNjdsmc35JDF1K5GR1SDk_YAPI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'EB Garamond';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ebgaramond/v13/SlGUmQSNjdsmc35JDF1K5GR1SDk_YAPI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
I have never been a fan of this approach since this means it would take at least 2 round trips after page load for the app to load the fonts. This will lead to a higher chance of seeing FOIT or FOUT. Furthermore, with this method, we will not be able to carry out certain optimizations like using preload
resource hint to prematurely fetch the fonts.
Hence, what I usually do is to download the WOFF2 file from the link in the above stylesheet and serve the font files by myself while also merging the above stylesheet into the app CSS bundle. With this approach, we can also add <preload>
resource hint to our app's HTML.
<html>
<head>
<link
rel="preload"
crossorigin
href="/eb-garamond-regular.woff2"
as="font"
/>
</head>
</html>
However, since the WOFF2 file is still around 40kB in size, there will still be a high chance for users to encounter FOUT when the app fails to load the font under 100ms. Hence, the goal here is to decrease the size of the font file even further. By looking up Google Fonts Docs, I came over the section Optimizing your font requests where Google mentions:
Oftentimes, when you want to use a web font on your website or application, you know in advance which letters you'll need. This often occurs when you're using a web font in a logo or heading.
In these cases, you should consider specifying a
text=
value in your font request URL. This allows Google to return a font file that's optimized for your request. In some cases, this can reduce the size of the font file by up to 90%.
Therefore, since all contents for this wiki app are static instead of being dynamic, what we can do is to extract the complete list of characters in our app and pass to https://fonts.googleapis.com/css?family=EB+Garamond:400,600&text=...
to fetch the stylesheet with the optimized font file declared inside. We will then download that optimized font and follow the same approach above.
To extract the characters, we can write a simple script that will scan through the JSON file endings.json
to extract the unique set of characters. The reason we only scan endings.json
for character set is that it is where 99% of our app content resides.
const fs = require("fs");
const path = require("path");
const content = fs.readFileSync(
path.resolve(__dirname, "../json/endings.json"),
"utf-8",
);
const endingsMap = JSON.parse(content);
const charactersMap = {};
for (const characterA of Object.keys(endingsMap)) {
for (const characterB of Object.keys(endingsMap[characterA])) {
for (const ending of endingsMap[characterA][characterB]) {
ending.split("").forEach(char => {
charactersMap[char] = 1;
});
}
}
}
console.log(
Object.keys(charactersMap)
.sort()
.join(""),
);
The result, in the end, is a 4x improvement in size as the WOFF2 font file now is just 10kB in size. 🤯
WebP and <picture>
Another problem is that for the initial Home page, we need to display a long list of available characters together with their avatars. Since the original avatar images are in uncompressed PNG format to support transparent background, loading multiple images at the same time can significantly decrease the page load time due to their considerably large sizes.
The solution here is to use the new image format WebP introduced by Google:
WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster.
WebP lossless images are 26% smaller in size compared to PNGs. WebP lossy images are 25-34% smaller than comparable JPEG images at equivalent SSIM quality index.
Lossless WebP supports transparency (also known as alpha channel) at a cost of just 22% additional bytes. For cases when lossy RGB compression is acceptable, lossy WebP also supports transparency, typically providing 3x smaller file sizes compared to PNG.
To begin with, we need to write a script (yet another 😎) to convert all the images in the app to their WebP versions. We will use a library called cwebp for this conversion.
const CWebp = require("cwebp").CWebp;
const path = require("path");
const fs = require("fs");
const glob = require("glob");
function getFiles(pattern) {
return new Promise((resolve, reject) => {
glob(pattern, (err, files) => (err ? reject(err) : resolve(files)));
});
}
(async function() {
const pngFiles = await getFiles("src/images/*.png");
Promise.all(
pngFiles.map(async file => {
const encoder = new CWebp(path.resolve(__dirname, `../${file}`));
// Set encoder quality image
encoder.quality(80);
// Set maximum compression level
encoder.compression(6);
try {
await encoder.write(
path.resolve(__dirname, `../${file.replace(".png", ".webp")}`),
);
} catch (err) {
console.log(err);
}
}),
);
const jpgFiles = await getFiles("src/images/*.jpg");
Promise.all(
jpgFiles.map(async file => {
const encoder = new CWebp(path.resolve(__dirname, `../${file}`));
// Set encoder quality image
encoder.quality(80);
// Set maximum compression level
encoder.compression(6);
try {
await encoder.write(
path.resolve(__dirname, `../${file.replace(".jpg", ".webp")}`),
);
} catch (err) {
console.log(err);
}
}),
);
})();
The WebP files show at max 10x improvement in sizes comparing to their original files. 💯
However, there is another problem. WebP format is not fully supported in all modern browsers. When users use those non-supported browsers to load the Wiki, all the images will not be displayed. This is where <picture>
comes in to solve the problem:
The HTML
<picture>
element contains zero or more<source>
elements and one<img>
element to offer alternative versions of an image for different display/device scenarios.The browser will consider each child
<source>
element and choose the best match among them. If no matches are found—or the browser doesn't support the<picture>
element—the URL of the<img>
element'ssrc
attribute is selected. The selected image is then presented in the space occupied by the<img>
element.
Thanks to <picture>
, we can display every image inside the app with prioritization on loading WebP images and their original images as the fallback. 🙌
import { VNode } from "preact";
type Props = {
character: Character;
};
function CharacterPortrait(props: Props): VNode<Props> {
const { character } = props;
return (
<picture className={styles.portraitPicture}>
<source
type="image/webp"
srcSet={`/${character}@1x.webp 1x, /${character}@2x.webp 2x, /${character}@3x.webp 3x`} />
<source
type="image/png"
srcSet={`/${character}@1x.png 1x, /${character}@2x.png 2x, /${character}@3x.png 3x`} />
<img
alt={`${character} Portrait`}
className={styles.portraitImg}
srcSet={`/${character}@1x.png 1x, /${character}@2x.png 2x, /${character}@3x.png 3x`}
/>
</picture>
);
}
Static Site Generator & template engine Pug.js
As a typical SPA (Single Page Application) app, when the page is first loaded, the initial HTML body
for our app will just have an empty <div id="root" />
. Only after JS bundles are fetched and parsed, Preact will kick in and start to render content into the empty <div>
. This has a major impact on our FMP (First Meaningful Paint) since the users will not see any content until the browser finishes fetching and parsing the scripts.
As a solution, we can replicate what static generators like GatsbyJS do by prematurely generating all the static HTML files for all available URLs inside the app at post-build phase (after Webpack bundling all assets).
First, we will need to generate all the available URLs in our Wiki to pass to the static generators later. Since there are only 3 main URL formats in our app i.e /
, /:characterA
and /:characterA/:characterB
, we can write a simple script to extract the full list of these URLs by scanning through endings.json
:
const fs = require("fs");
const path = require("path");
const content = fs.readFileSync(
path.resolve(__dirname, "../json/endings.json"),
"utf-8",
);
const endingsMap = JSON.parse(content);
const availableURLs = ["/"];
for (const characterA of Object.keys(endingsMap)) {
availableURLs.push(`/${characterA}`);
for (const characterB of Object.keys(endingsMap[characterA])) {
availableURLs.push(`/${characterA}/${characterB}`);
}
}
fs.writeFileSync(
path.resolve(__dirname, "../json/urls.json"),
JSON.stringify(availableURLs, null, 2),
"utf-8",
);
Secondly, we need to use a template engine to allow us to dynamically inject unique content for each generated static HTML. Since I'm familiar with Pug.js, I decided to use it as the template engine here.
- var title = "Fire Emblem Three Houses Endings"
- var description = "Complete collection of all available endings for every character in Fire Emblem Three Houses"
doctype html
html(lang="en")
head
meta(charset="utf-8")/
meta(http-equiv="X-UA-Compatible" content="IE=edge")/
meta(name="viewport" content="width=device-width, initial-scale=1.0")/
title= title
meta(name="description" content=description)/
meta(name="theme-color" content="#5f5273")/
meta(property="og:title" content=title)/
meta(property="og:type" content="website")/
meta(property="og:description" content=description)/
meta(property="og:image" content="https://fe3h.noobsaigon.com/og-image.jpg")/
meta(property="og:url" content=url)/
meta(property="og:site_name" content="Three Houses Endings")/
meta(name="twitter:card" content="summary_large_image")/
link(rel="canonical" href=url)/
link(rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png")/
link(rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png")/
link(rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png")/
link(rel="manifest" href="/app-manifest.json")/
link(rel="dns-prefetch preconnect" href="https://www.google-analytics.com" crossorigin)/
link(rel="preload" crossorigin href="/eb-garamond-regular.woff2" as="font")/
link(rel="preload" crossorigin href="/eb-garamond-semibold.woff2" as="font")/
each jsonURL in jsonURLs
link(rel="preload" crossorigin href=jsonURL as="fetch")/
each jsBundleURL in jsBundleURLs
link(rel="preload" href=jsBundleURL as="script")/
style(data-href=cssInline.url) !{cssInline.content}
body
div(id="root") !{appContent}
script !{webpackRuntimeInline}
script !{gaInline}
each jsBundleURL in jsBundleURLs
script(src=jsBundleURL async="")
script !{swInline}
script(src="https://www.google-analytics.com/analytics.js" async="")
From the template, we can see that a few optimizations are implemented:
preload
resource hint for JSON files if existspreload
resource hint for JS bundlespreload
resource hint for font filesdns-prefetch
andpreconnect
forhttps://www.google-analytics.com
to improve Google Analytics initialization time- Inline CriticalCSS applied instead of fetching external CSS bundles.
- Service Worker initialized
After setting up the template, our last step is to write a script (last one 🤞) to generate all static HTMLs:
import { h } from "preact";
import render from "preact-render-to-string";
import pug from "pug";
import fs from "fs";
import path from "path";
import pathToRegexp from "path-to-regexp";
const manifestJSON = require("../../build/manifest.json");
const availableURLs = require("../../json/availableURLs.json");
// CSS Bundles
const mainCSSURL = manifestJSON["main.css"];
const mainCSSFilename = mainCSSURL.split("/").pop();
const mainCSSContent = fs.readFileSync(
path.resolve(__dirname, `../../build/${mainCSSFilename}`),
"utf-8",
);
// JS Bundles
const runtimeJSURL = manifestJSON["runtime.js"];
const runtimeJSFilename = runtimeJSURL.split("/").pop();
const runtimeJSContent = fs.readFileSync(
path.resolve(__dirname, `../../build/${runtimeJSFilename}`),
"utf-8",
);
const vendorJSURL = manifestJSON["vendors~main.js"];
const mainJSURL = manifestJSON["main.js"];
const swInline = terser.minify(
fs.readFileSync(path.resolve(__dirname, "./sw.js"), "utf-8"),
).code;
const gaInline = terser.minify(
fs.readFileSync(path.resolve(__dirname, "./ga.js"), "utf-8"),
).code;
(async function() {
const endingPathRegex = pathToRegexp("/:characterA/:characterB");
for (const url of availableURLs) {
const appContent = render(...);
const locals = {
appContent,
cssInline: { content: mainCSSContent, url: mainCSSURL },
jsBundleURLs: [vendorJSURL, mainJSURL],
jsonURLs: [],
webpackRuntimeInline: runtimeJSContent,
gaInline,
swInline,
};
const endingPathMatch = endingPathRegex.exec(url);
if (endingPathMatch != null) {
const [,characterA, characterB] = endingPathMatch[1];
locals.jsonURLs = [
characterA <= characterB
? `/${characterA}_${characterB}.json`
: `/${characterB}_${characterA}.json`,
];
}
const html = pug.renderFile(
path.resolve(__dirname, "../../src/templates/index.pug"),
locals,
);
fs.writeFileSync(
path.resolve(
__dirname,
`../../build${url === "/" ? "/index.html" : `${url}.html`}`,
),
html,
"utf-8",
);
}
})();
PWA & Service Worker
With all the optimizations so far, our app has become as minimal and performant as it can get. However, it still needs one more feature to be regarded as a PWA (Progressive Web App). So first, how can an app be considered as a PWA? According to Google:
Progressive Web Apps are user experiences that have the reach of the web, and are:
- Reliable - Load instantly and never show the downasaur, even in uncertain network conditions.
- Fast - Respond quickly to user interactions with silky smooth animations and no janky scrolling.
- Engaging - Feel like a natural app on the device, with an immersive user experience.
Voila! So the last thing we need to implement is adding offline support, which will be powered by Service Worker. Development with Service Worker has tremendously improved thanks to their detailed documentation and Google's introduction of workbox - a collection of JavaScript libraries for Progressive Web Apps.
To support Offline Mode, we need these 2 main features:
- Precache all required bundle assets for the app to function normally
- A fallback HTML app shell for the app to use when it has no access to the static HTML files since there are too many of these to be precached
First, we will create a base Service Worker file for workbox
to build on top of it. The base service worker will be configured to cache all requested fonts & images. Besides, it will tell the browser to use the fallback app shell when the browser fails to fetch the static HTML files under situations like no internet connection.
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
);
workbox.precaching.precacheAndRoute([]);
workbox.precaching.cleanupOutdatedCaches();
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// Cache fonts
workbox.routing.registerRoute(
/\.woff2$/,
new workbox.strategies.CacheFirst({
cacheName: "fonts",
plugins: [
new workbox.expiration.Plugin({
maxEntries: 5,
maxAgeSeconds: 60 * 60 * 24 * 365,
}),
],
}),
);
// Cache images
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg|webp|svg)$/,
new workbox.strategies.CacheFirst({
cacheName: "images",
plugins: [
new workbox.expiration.Plugin({
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
}),
],
}),
);
// Offline fallback app shell
workbox.routing.registerRoute(
({ event }) => event.request.mode === "navigate",
({ url }) =>
fetch(url.href).catch(() =>
caches.match(workbox.precaching.getCacheKeyForURL("/layout.html")),
),
);
Next, we will create the fallback HTML app shell by extending the static generator script in the previous section. We will also use workbox
to generate the complete service worker file with an injected list of assets to be precached. For our app, the precached assets are JS bundles and all JSON files for paired endings.
// ...
(async function() {
// ...
// Generate fallback layout for 404 / offline mode
const locals = {
appContent: "",
cssInline: { content: mainCSSContent, url: mainCSSURL },
jsBundleURLs: [vendorJSURL, mainJSURL],
jsonURLs: [],
webpackJSInline: runtimeJSContent,
gaInline,
swInline,
};
const html = pug.renderFile(
path.resolve(__dirname, "../../src/templates/index.pug"),
locals,
);
fs.writeFileSync(
path.resolve(__dirname, "../../build/layout.html"),
html,
"utf-8",
);
// Generate service worker file
const { count, size, warnings } = await injectManifest({
swSrc: path.resolve(__dirname, "../../src/sw.js"),
swDest: path.resolve(__dirname, "../../build/sw.js"),
globDirectory: path.resolve(__dirname, "../../build/"),
globIgnores: ["**/runtime-*.js"],
globPatterns: ["**/*.{js,json}", "layout.html"],
dontCacheBustURLsMatching: /\.js$/,
});
})();
With this, we have reached the end of our performance journey. 😓 The performance of the app at this stage has become as stellar as it can be according to Lighthouse Benchmark. 🎉
Release
As everything was ready, I decided to release the app and shared my work with the Fire Emblem community. To be honest, I was a bit nervous wondering if there's anyone who would find my work as useful as I had thought it would be.
Thankfully, the app has been well received by the community and a lot of members said that they had always felt the same need for something like this Wiki previously. Some Reddit users even gifted me Reddit Golds, which was the first time for me and made me happier than ever. 💙
From Google Analytics, the number of users for the first 3 days accumulated to around 6000 users. 🙌
Looking at these data, another surprising finding was that the majority of users using the app are mobile users instead of desktop users. 🧐 Our hard work on a mobile-first experience gladly did not go to waste. 🙏
Final Thoughts
This concluded the case study of my recent work. The journey of creating this app has been super enjoyable and greatly improved my knowledge on various aspects of web development. It also gave me a sense of reward by contributing to another community that I frequently participate in besides the usual open-source community. 👾 The full source code can be found under:
https://github.com/imouto1994/edelgard
So until the next project comes, see you when I see you! ✌️