Written by: Marlon Colca
Posted on 20 Sep 2025 - 14 days ago
typescript nodejs vue games
Time to peek under the hood! The useGame composable orchestrates everything: deck creation, preview timing, scoring, and persistence. We will dissect the most important pieces.
Time to peek under the hood! The useGame
composable orchestrates everything: deck creation, preview timing, scoring, and persistence. We will dissect the most important pieces.
import { ref, computed, onBeforeUnmount } from "vue";
import { DIFFICULTIES, PREVIEW_MS } from "./constants";
import { shuffle } from "./utils";
import { getImages } from "./imageService";
import { useSettings } from "./useSettings";
import { useScores } from "./useScores";
ref
and computed
keep the UI in sync with state changes.DIFFICULTIES
describes how many unique cards and matches each mode requires.getImages
abstracts whether we use emojis or Giphy assets.This is the heart of the startNewGame
flow:
const deck = ref<Card[]>([]);
const previewing = ref(false);
const previewLeftMs = ref(0);
async function startNewGame() {
busy.value = true;
gameOver.value = false;
score.value = 0;
picked.value = [];
firstPickAt.value = null;
const { unique, match } = meta.value;
const images = await getImages(settings, unique);
const cards: Card[] = [];
let idCounter = 1;
for (const img of images) {
for (let k = 0; k < match; k++) {
cards.push({
id: idCounter++,
imageId: img.id,
imageUrl: img.url,
flipped: false,
matched: false,
});
}
}
deck.value = shuffle(cards);
deck.value.forEach((c) => (c.flipped = true));
showStart.value = false;
previewing.value = true;
const endAt = Date.now() + previewMs.value;
previewLeftMs.value = previewMs.value;
previewTick = setInterval(() => {
previewLeftMs.value = Math.max(0, endAt - Date.now());
}, 100);
previewTimeout = setTimeout(() => {
deck.value.forEach((c) => {
if (!c.matched) c.flipped = false;
});
busy.value = false;
previewing.value = false;
clearPreviewTimers();
}, previewMs.value);
}
Highlights:
unique
ร match
).previewTick
.busy
blocks clicks while cards are auto-flipped.When the player clicks cards, we push indexes into picked
. Once we reach the required match
size, we evaluate:
const groupSize = meta.value.match;
if (picked.value.length === groupSize) {
busy.value = true;
const chosen = picked.value.map((i) => deck.value[i]);
const allSame = chosen.every((x) => x.imageId === chosen[0].imageId);
const resolve = () => {
picked.value = [];
firstPickAt.value = null;
busy.value = false;
if (deck.value.every((x) => x.matched)) {
gameOver.value = true;
addScore({
id: `${Date.now()}`,
date: new Date().toISOString(),
difficulty: settings.difficulty,
score: score.value,
totalCards: deck.value.length,
matchSize: meta.value.match,
source: settings.source,
});
}
};
if (allSame) {
const delta = firstPickAt.value ? Date.now() - firstPickAt.value : 0;
const bonus = Math.max(0, 1000 - delta);
score.value += 100 + bonus;
chosen.forEach((c) => (c.matched = true));
setTimeout(resolve, 250);
} else {
setTimeout(() => {
picked.value.forEach((i) => (deck.value[i].flipped = false));
resolve();
}, 700);
}
}
useScores
.clearPreviewTimers
, onBeforeUnmount
) prevent leaks.useSettings
, useScores
) without a heavyweight store.With the logic dialed in, let us paint the interface that brings it to life. ๐จ
With the game logic solid, the UI binds everything together. Vue Single File Components keep the markup expressive while reusing the composables underneath.
21 Sep 2025 - 13 days ago