## recent communique | New Year, New Tech | Mon Jan 29 2024

animated GIF of football player in gear during an interview shouting "Woo. We're back baby"

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

animated gif of a min in front of a chalkboard with various items written on it with the caption "And thats the plan"

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 a opengraph image of the Discord website 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.

animated gif of a cartoon robot taking a photo with a camera with the caption "neat"

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

flow chart of how an dynamic opengraph image is created in Project24

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.

example of a open graph image of Project24's dynamic score 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.

example of place holder opengraph image of Project24

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.

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

@zhymon.bsky.social

@Zhymon@mastodon.online

Follow SWARM on Social for even more updates