Hi there and welcome back to the blog. In this month's issue, I want to walk you through how I created what I suspect will be a critical part of Project 24's success: The ability to easily and quickly share scores on social media. As a quick refresher here is the simple premise of Project 24:
A wave-based arena shooter where every day you get a shot at the top spot of a global leaderboard. There is only one - catch each day randomly chosen modifiers will change how the game plays.
One of the clear inspirations for Project 24 is Wordle's daily puzzle challenge, but did you know one of the key pieces of Wordle's rise to fame was its easy-to-share scores? This caught me by surprise when I stumbled upon this in my research on Wordle:
Wardle created the game to play with his partner, eventually making it public in October 2021. The game gained popularity in December 2021 after Wardle added the ability for players to copy their daily results as emoji squares, which were widely shared on Twitter. Wordle - Wikipedia
This became a key pillar that I wanted to emulate when developing Project 24. So I broke it down into a few key goals and concepts:
- It should only take a couple of clicks/taps to share a score
- It should show the score, leaderboard rank, and modifiers of the day
- It should drive traffic to the game website
- Reusability and extendibility are paramount
So before embarking on the journey to build Project 24 one of the first challenges I gave myself was building an API for the game using NestJS, a wonderful framework for creating quality APIs. After creating an API to handle most of the player interactions (which we will cover in a future dev blog I'm sure) I turned my attention to the social share aspect. One of the first thoughts I had to tackle this was utilizing "Open Graph Images" or "og-images" for short. For the uninitiated og-images are previews for websites that show up in places such as social media posts, messaging apps such as iMessage or Discord, and anywhere rich messaging is supported.
Example of what a Open Graph Image may look like on Facebook for the Discord website
Open Graph is a protocol introduced by Facebook in 2010 to allows deeper integration between Facebook and any web page. It allows any web page to have the same functionality as any other object on Facebook. You could control how your website is being displayed on Facebook. Now, other social media sites such as Twitter, LinkedIn are recognizing Open Graph meta tags. Learn more about Open Graph Tags at OpenGraph.xyz
Why use something like that you may ask? Well, In my years as a software developer, I came to utilize and love NextJS from Vercel. One of the most powerful aspects of NextJS or Vercel is the ability to create lightning-fast, serverless, API functions. Vercel also created a package called, "Open Graph Image Generation" which allows a developer to create Open Graph Images on the fly using NextJS API routes. These two tools combined with the NestJS API that I've already built and a Vercel-hosted short link generator allow me to create an API route that will generate a link that when shared anywhere that supports og-images will show all the information we want. As a bonus the link generated when clicked will take a user to the game's website; another one of our requirements.
Now let's look at a flowchart for how all this works and then we will break it down step by step.
Click to view large image of flowchart
First when a player interacts with the UI in the game to get a shareable link a call is made to the API with a reference to the score the user is currently viewing.
/// TYPESCRIPT - NESTJS
// This is used to populate info from the database
const dataLoader = [
{ path: "user", model: User.name, justOne: true },
{
path: "leaderboard",
model: Leaderboard.name,
justOne: true,
populate: { path: "activeMods", model: Mod.name, justOne: false },
},
];
@Injectable()
export class SocialService {
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly logger: Logger,
@InjectModel(Score.name)
private scoreService: Model<ScoreDocument>
) {}
async createShortLinkForScore(
scoreId: MongooseSchema.Types.ObjectId
): Promise<string> {
try {
if (!isValidObjectId(scoreId)) {
throw new ServerError("Invalid ObjectId");
}
const score: any = await this.scoreService
.findById(scoreId)
.populate(dataLoader);
// If the refrenced score already has a short link simply return it
if (score.shortLink !== "") {
return score.shortLink;
} else {
// Make a call to the short link generator creating the shortlink url
const res: any = await lastValueFrom(
this.httpService
.get(
`https://${this.configService.get<string>("SHORT_URL_HOST")}?u=${this.configService.get<string>(
"PROJECT_24_WEB_URL"
)}/?scoreId=${scoreId}`
)
.pipe(
map((response) => {
return response;
})
)
);
// if everything goes well return the url to the user
if (!res) {
throw new ServerError(res);
} else {
await this.scoreService.updateOne(
{ _id: scoreId },
{ $set: { shortLink: res?.data?.link } }
);
return res?.data?.link;
}
}
} catch (error) {
this.logger.error(error);
throw new ServerError(error);
}
}
}
The API reaches out to our Vercel-hosted short link generator and generates a short link to the Project 24 website which is a NextJS app hosted on Vercel, included in the original URL is the unique ID of the score. It also writes the short link in the same object used to generate it to reuse the link should a user request it again.
/// JavaScript- NODEJS
const MongoClient = require('mongodb').MongoClient;
const request = require('request');
const regex = /^(http[s]?:\/\/){0,1}(www\.){0,1}[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,5}[\.]{0,1}/; // validate url
module.exports = async(req, res) => {
const url = req.query.u;
if (url == undefined) { // No URL
res.json({
status: false,
msg: "Url is missing"
})
} else if (!regex.test(url)) { // Validate url
res.json({
status: false,
msg: "Bad URL kindly recheck url and send again"
})
} else { // url is ok
MongoClient.connect(process.env.DB_URL, function(err, db) {
// Connect to database
if (err) {
res.json({
status: false,
msg: "Cannot connect with Database"
})
} else {
// Get the time
var options = {
'method': 'GET',
'url': 'https://time.akamai.com/'
};
request(options, function(error, response) {
if (error) { // If theres an error fetching the time
res.json({
status: false,
msg: "Failed to get time data"
})
} else {
// Generate a random string for an ID
function makeId(length) {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
var rString = makeId(5);
var timestamp = Number(response.body) + Number('19800'); // gmt to ist
var tidd = rString; // Add the generated string
// Save to the database....
var dbo = db.db("shortner"); // Database name
var obj = {
tid: tidd,
ist: timestamp,
url: `${url}&utm_source=GameClient&utm_medium=GameShareLink&utm_campaign=SocialShareLink&utm_content=Link`,
clicks: 0,
name: `Project24_ShareLink_${tidd}`
};
dbo.collection("data").insertOne(obj, function(errorr, result) {
if (errorr) { // if there is an error while writing
res.json({
status: false,
msg: "Error while write on database"
})
} else {
var link = process.env.APP_URL + "/?i=" + result.ops[0].tid;
res.json({ // if everything goes well return the data
status: true,
link: link,
unique_id: result.ops[0].tid,
timestamp: result.ops[0].ist,
clicks: result.ops[0].clicks
})
};
db.close();
});
};
});
};
});
};
};
When the short link is used, the NextJS app uses the score ID included in the URL to pull the score data from the database; the same database used by the games API. If the NextJS app is sent a score short link the NextJS app utilizes an API route that creates an og-image using the score data, if not a default og-image is used.
// TYPESCRIPT - NEXTJS
export const getServerSideProps: GetServerSideProps<{
score: any,
}> = async ({ query }) => {
let score: ScoreObject | null = null;
const scoreId = query.scoreId;
// This runs before the URL is even loaded so first pull the score from the ID used in the URL
if(scoreId !== undefined) {
const gql = `
query {
getScoreById(scoreObjectId:"${scoreId}") {
_id
country
leaderboard {
_id
activeMods {
attribute
affectObjectWithTag
_id
iconUrl
name
value
}
expiresAt
}
platform
scoreValue
user {
_id
backgroundUrl
eosId
username
}
createdAt
}
}
`;
const data = await graphQLRequest(`${process.env.P24_API_URL}/graphql`, gql);
score = data?.data?.getScoreById;
}
return { props: { score } }
}
export default function Index({score}: InferGetServerSidePropsType<typeof getServerSideProps>) {
let ogImage = "";
// Save the data if its present in the URL
if(score !== null){
const dateParse = new Date(parseInt(score.createdAt));
const date = dateParse.toLocaleDateString();
const mod1Img = score?.leaderboard?.activeMods[0]?.iconUrl ?? "https://placehold.co/64x64.jpg";
const mod2Img = score?.leaderboard?.activeMods[1]?.iconUrl ?? "https://placehold.co/64x64.jpg";
const mod3Img = score?.leaderboard?.activeMods[2]?.iconUrl ?? "https://placehold.co/64x64.jpg";
const mod1Text = score?.leaderboard?.activeMods[0]?.name ?? "";
const mod2Text = score?.leaderboard?.activeMods[1]?.name ?? "";
const mod3Text = score?.leaderboard?.activeMods[2]?.name ?? "";
// Create the ogImage url based on the API route in this app
ogImage = `https://${process.env.HOST_NAME}/api/score-image?userId=${score.user.username}&date=${date}&rank=12&score=${score.scoreValue}&mod1_text=${mod1Text}&mod2_text=${mod2Text}&mod3_text=${mod3Text}&mod1_img=${mod1Img}&mod2_img=${mod2Img}&mod3_img=${mod3Img}`
}
else{
// If we aren't loading the score simply use the placeholder image
ogImage = "https://placehold.co/1200x630.jpg";
}
return (
<>
<Head>
<title>Project 24</title>
<meta
name="title"
content="Project 24 | You got 24 hours to top the leaderboard!"
/>
<meta
name="description"
content="Lorem ipsum lorem ipsum lorem ipsum | A game by SWARM Creative"
/>
......
<meta
property="twitter:image"
content={ogImage} // < ----- SEE THE OG IMAGE IS SET HERE
/>
..........
);
}
Finally, when a rich messaging app sees a short link the generated image is shown as the og-image showing the username, score, rank, and modifiers. Below is an example of the OpenGraph Preview of the Project 24 with and without a generated og-image.
Clearly a work in progress. As you can see this image contains the rank, score, the players name and more. All created on the fly.
If a link to the games website is shared without a score a simple og-image is used instead, a placeholder currently in this case
So let's review to make sure we are hitting our goals for this feature:
It should only take a couple of clicks/taps to share a score
- Thanks to Vercel, this data is returned to the user in less than a second in most cases
It should show the score, leaderboard rank, and modifiers of the day
- Vercel's OG Image Generation package allows us to do this on the fly for any score
It should drive traffic to the game website
- Our short link generator will send users to the site upon clicking the link
Reusability and extendibility are paramount
- We save any generated URL to the same score object used to create the URL. If a score already has a URL it is returned to the player upon request.
- The games API, short link tool, and website (the NextJS app) all connect to the same database to save time and allow reusability
- Since all of the og-images are generated on the fly we can modify them down the road with little to no effort.
So that's a walkthrough of how I created my version of the share feature from Wordle, and how it works; neat right?! Below you'll find links to some of the tech used to bring this to life.
- Source for URL Shortner I used this open-source project as a starting point for the short link generator, and I've made a few modifications to have it fit my needs such as counting clicks and adding the ability to add names to each link.
- Vercel
- NextJS by Vercel
- Open Image Generation by Vercel
- NestJS Homepage
- Railway I use Railway for hosting the API and database used for Project 24, I can't recommend it enough. I like to describe it as "LEGOS for backend services".
Writers Note, July 2024: Future Simon here, recently we made a move over to Supabase and Postgres over NestJS and Railway. While the technology stack is different the way the OpenGraph images work with the url shortener and the Project 24 website is the exact same.
Cheers,
Simon Norman
Director, Agents of SWARM
Follow SWARM on Social for even more updates