matchmaking and updating photos' ratings

date published:

category: dev diary

tags: formulaone, racemash, vue, vuetify

Hey folks!

I’m aware that this project is already taking too long for what it is supposed to be, but I got sidetracked with a couple of collaborative projects I’m involved in over the past week. I also had to deal with hay fever, which effectively limited my bandwidth and productivity.

But yes, I also managed to allocate some of that time to revisit RaceMash and implement logic for picking two random photos for a user to vote, as well as updating the photos’ ratings using the Glicko-2 system. And in this post, I’m going to go through the code ended up with and explain some of the decisions I made when writing it.

With that said, let’s just jump into it!

Migrating from Appwrite backend

Since last week I elected to get rid of Appwrite from my app’s stack entirely, opting to use localStorage and my Vue project’s public folder as substitutes for fully-fledged database and file storage solutions respectively.

I also revised the structure of each photo and vote document. For the former, I replaced the id field with a unique fileName of each photo, and also renamed ratingDeviation and volatility to rd and vol respectively. While they’re less descriptive, they match the return value of glicko2-lite, a library I use to update each photos ratings, but we’ll get to that bit shortly.

As for the votes, I could finally switch over to a photos array field, but instead of storing carbon copies of entire objects, I instead chose to store their fileNames there. I retained the result field and its values (ie. 0, 0.5, and 1).

With a proper model in place, I placed my images in /public/images, and created a photos.json file inside the project’s src directory. The latter is just an array of 24 objects that have all the aforementioned properties, as well as an altText and rating.

I also made sure to create appropriate type definitions for my photos and documents inside models/index.ts:

export interface Photo {
  fileName: string;
  altText: string;
  rating: number;
  rd: number;
  vol: number;
}

export interface Vote {
  photos: [string, string];
  result: 0 | 0.5 | 1;
}

export interface Database {
  photos: Photo[];
  votes: Vote[];
}

Coding up a useVote composable

Persistent photo ratings and vote history

Although in my last post I wrote that I’d use lowdb, I changed my mind and decided to once again take advantage of Vue 3’s powerful reactivity system and watchEffect to create a persistent state solution.

I created a useVote.ts file in the composables folder and placed a reactive object called db which is initialised to the value held in localStorage under the same key and falls back to an object with photos from the photos.json file and an empty votes array. Then I added a ref to store photos to display on the vote page and the aforementioned watchEffect call to save the db to localStorage.

import { reactive, ref, watchEffect } from 'vue';

import defaultPhotos from '@/photos.json';
import { Database, Photo } from '@/models';

const db = reactive<Database>(
  JSON.parse(localStorage.getItem('db') as string) || {
    photos: defaultPhotos,
    votes: []
  }
);

const photosInCurrentVote = ref<Photo[]>([]);

watchEffect(() => localStorage.setItem('db', JSON.stringify(db)));

Picking two photos for a vote

I had a place to store my photo entries and votes, but I still didn’t do anything with that ref. So I added a pickPhotosForNewVote function. First thing I needed it to do was to filter through all photos and leave only the ones that hadn’t been paired with every other image apart from itself. In other words, I wanted to keep the entries that did not appear the toal number of photos - 1 times across all votes.

const photosForFirstPick = db.photos.filter(({ fileName }) => {
  const appearanceCount = db.votes.filter((vote) =>
    vote.photos.includes(fileName)
  ).length;

  return appearanceCount !== db.photos.length - 1;
});

With that list in place, I just needed to grab a random item from this array like so:

const firstPick =
  photosForFirstPick[randomNumber(0, photosForFirstPick.length - 1)];

I stole… I mean borrowed the randomNumber function from MDN, because why not.

Anyway, with first photo down, I still had to pick a second one that’s not the first photo and that hadn’t been paired with it yet. And last but not least, I just needed to populate the right ref:

const photosForSecondPick = db.photos.filter(({ fileName }) => {
  const votesWithFirstPick = db.votes.filter(({ photos }) =>
    photos.includes(firstPick.fileName)
  );

  const fileNamesToExclude = new Set([
    firstPick.fileName,
    ...votesWithFirstPick.flatMap(({ photos }) => photos)
  ]);

  return fileNamesToExclude.has(fileName) === false;
});

const secondPick =
  photosForSecondPick[randomNumber(0, photosForSecondPick.length - 1)];

photosInCurrentVote.value = [firstPick, secondPick];

Submitting a vote

With logic to create a vote in place, I could move on to implementing a function for casting votes. I wanted it to accept a single argument for the result that could be set to one of three values: 0 (Photo 2 wins), 0.5 (a draw), or 1 (Photo 1 wins).

From there, I just had to grab the photos’ fileNames, prepend an appropriate object to the votes array and call the pickPhotosForNewVote function to generate a new voting pair.

const submitVote = (result: 0 | 0.5 | 1) => {
  const photos = photosInCurrentVote.value.map(({ fileName }) => fileName) as [
    string,
    string
  ];

  db.votes.unshift({ photos, result });
  pickPhotosForNewVote();
};

Updating photos’ ratings

Next up was setting new ratings every 12 new vote submissions. Why 12? Because 276 (ie. the number of all possible voting pairs given 24 photos) is divisible by that number, and the Glicko-2 system apparently works best for 10-15 matches per tournament (I actually misread that recommendation, since this was supposed to be 10-15 matches per player in a torunament, but let’s say I decided to experiment a little with my app, ok?).

I also opted to use glicko2-lite, because it offers the best TypeScript support and flexibility when it comes to tracking matches, handling only the new ratings’ calculations based on the match history you feed into the glicko2 function.

So my plan was to group the votes into an object where each key is a photo’s file name, with every value being an array of said parameters, ie. the opponent’s rating, rating deviation and its result. So, if a vote has the result set to 1, then in the opponent’s array that vote will have the result set to 0 and vice versa. With all votes groupped by photos, I could then calculate the new rating params and override these photos’ rating params.

That was the spec, here’s the actual function responsible for updating these ratings:

const updateRatings = () => {
  // The first vote in the array is the most recent one.
  // Therefore calling reverse will order these votes chronologically.
  const twelveMostRecentVotes = db.votes.slice(0, 12).reverse();

  const votesGrouppedByPhotos = twelveMostRecentVotes.reduce((obj, vote) => {
    for (const [index, fileName] of vote.photos.entries()) {
      const opponentFileName = vote.photos[1 - index];
      const opponent = db.photos.find(
        ({ fileName }) => fileName === opponentFileName
      )!;

      const voteParams = [
        opponent.rating,
        opponent.rd,
        index === 0 ? vote.result : 1 - vote.result
      ] as [number, number, number];

      if (obj[fileName]) {
        obj[fileName].push(voteParams);
      } else {
        obj[fileName] = [voteParams];
      }
    }

    return obj;
  }, {} as Record<string, [number, number, number][]>);

  for (const [photoFileName, voteHistory] of Object.entries(
    votesGrouppedByPhotos
  )) {
    const photo = db.photos.find(({ fileName }) => fileName === photoFileName)!;
    const updatedRatingParams = glicko2(
      photo.rating,
      photo.rd,
      photo.vol,
      voteHistory
    );

    Object.assign(photo, updatedRatingParams);
  }
};

Of course, I also made sure to call this function in every 12 new vote submissions:

const submitVote = (result: 0 | 0.5 | 1) => {
  const photos = photosInCurrentVote.value.map(({ fileName }) => fileName) as [
    string,
    string
  ];

  db.votes.unshift({ photos, result });

  if (db.votes.length % 12 === 0) {
    updateRatings();
  }

  pickPhotosForNewVote();
};

Creating a vote page

Of course, I wasn’t quite done yet, because I was yet to implement the actual voting page. I revisited the Vote.vue component inside my views folder. I first placed this tiny little script tag at the top of the file:

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useVote } from '@/composables/useVote';

const { photosInCurrentVote, pickPhotosForNewVote, submitVote } = useVote();

onMounted(pickPhotosForNewVote);
</script>

For the template, I wanted to center the content, ie. a heading, short but descriptive manual, the photos themselves and voting buttons to be centered both horizontally and vertically. Photos could be displayed side-by-side on large desktop screens, and one on top of the other on anything smaller.

<template>
  <section class="w-100 h-100 d-flex flex-column justify-center align-center">
    <div class="text-center">
      <h1 class="mt-4 mb-2 text-h3">Vote</h1>
      <p class="px-4 text-h6 font-weight-regular">
        Which photo do you like more? Click one of three buttons below to
        choose.
      </p>
    </div>
    <div
      class="py-lg-6 py-3 d-flex flex-lg-row flex-lg-row flex-column align-center"
    >
      <div
        v-for="(photo, index) in photosInCurrentVote"
        :key="photo.fileName"
        class="d-flex flex-column align-center px-5 py-4"
      >
        <v-img
          :src="`/images/${photo.fileName}`"
          lazy-src="BASE64_URL_OF_GRAY_IMAGE_THAT_SAYS_LOADING"
          :alt="`Photo ${index + 1} - ` + photo.altText"
          :aspect-ratio="16 / 9"
          max-width="480"
          max-height="270"
        />
        <p class="mt-2 text-h6">Photo {{ index + 1 }}</p>
      </div>
    </div>
    <div id="vote-btns" class="mb-4 d-flex justify-center flex-wrap">
      <v-btn size="large" @click="submitVote(1)">Photo 1</v-btn>
      <v-btn size="large" @click="submitVote(0)">Photo 2</v-btn>
      <v-btn size="large" @click="submitVote(0.5)">I can't decide</v-btn>
    </div>
  </section>
</template>

Last but not least, since there aren’t any Vuetify flex-gap utility classes, I added this teeny-tiny rule via a scoped style tag:

#vote-btns {
  gap: 1.25rem;
}

Wrapping up

Thank you so much for reading in… assuming anyone’s reading this in the first place, but I don’t mind if nobody is, since this blog mostly serves as a personal journal of mine anyway. Next up - tracking user’s progress and displaying F1 fun facts at the 25%, 50%, and 75% marks.