
5 improved games — the day the portfolio became a showcase
The portfolio already had structure, animations, i18n — but it lacked soul. The 5 mini-games were there, functional, but kind of forgotten. Each one made at a different time, with different styles and quality levels.
It was time to treat them as a showcase, not as decoration.
Where they were
The games started as static HTML files in public/games/, each in its own subdirectory:
public/games/
├── simon-game/ # Sequential memory game (Simon Says)
├── asteroid-dodge/ # Asteroid dodging in 2D space
├── code-typing/ # Typing speed test
├── memory-matrix/ # Light grid memorization
└── terminal/ # Interactive terminal simulator
They worked — but they were only accessible via direct URL. No presence on the home page. No cover image. No context.
What I changed
GameShowcase — the horizontal display
The first step was creating a horizontal scroll carousel component that displayed each game with:
- Cover image (webp 180×110)
- Name and description pulled from the repo
- Play button that loads the game inline
- Dedicated embed with loading state and close button
// GameShowcase.tsx — the showcase that was missing
const GAME_IMAGES: Record<string, string> = {
"simon-game": "/games/simon-game.webp",
"asteroid-dodge": "/games/asteroid-dodge.webp",
"code-typing": "/games/code-typing.webp",
"memory-matrix": "/games/memory-matrix.webp",
"terminal": "/games/terminal.webp",
};
The scrolling is native (overflow-x: auto), with navigation arrows and snap-points to prevent leaving a card half-visible. Works by dragging on mobile, scroll wheel on desktop.
API route — smart proxy
I created an API route /api/game/[slug] that serves each game’s static HTML. This gave me control over cache headers, CSP, and logging without needing to modify the original HTMLs.
// app/api/game/[slug]/route.ts
export async function GET(
_request: Request,
{ params }: { params: { slug: string } }
) {
const gamePath = path.join(process.cwd(), 'public', 'games', params.slug, 'index.html')
if (!fs.existsSync(gamePath)) {
return new Response('Game not found', { status: 404 })
}
const html = fs.readFileSync(gamePath, 'utf-8')
return new Response(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
})
}
Analytics on every play
Each “Play” click fires an Umami event with the game name. Within two weeks I already see a pattern: code-typing is the most played, followed by asteroid-dodge.
const playGame = useCallback((name: string, url: string | null) => {
track({ type: "game_play", game: name })
// ... loads the iframe
}, [track])
Individual improvements
Each game received specific attention:
Simon Game:
- More vibrant colors with glow on hover
- Scale animation on click (110% → 100%)
- Persistent score during the session
Asteroid Dodge:
- Touch events enabled (
touch-action: none) - Performance:
requestAnimationFrameinstead of setInterval - Mobile-first: full screen on phone
Memory Matrix:
- Responsive grid (3×3 on mobile, 4×4 on desktop)
- Visual timer with progress bar
- Progressive difficulty each round
Code Typing:
- Expanded snippet bank (30+ real code snippets)
- WPM + accuracy in real time
- Syntax highlighting for the current snippet
Terminal:
- 15 portfolio-themed commands
- Typing effect with variable speed
- Command history via sessionStorage
The embed that became a frame
The coolest technical part was the embed system. When the user clicks “Play”, an iframe is dynamically created within a themed frame:
┌─ simon-game ───────────────────── [] ─┐
│ │
│ [game loaded here] │
│ │
└─────────────────────────────────────────────┘
The frame has:
- Green status indicator (like a real terminal)
- Dynamic game title
- Close button that clears the iframe (frees memory)
- Loading spinner while the HTML loads
{loading && (
<div className="flex items-center justify-center h-[450px]">
<div className="w-8 h-8 border-2 border-[var(--accent)]
border-t-transparent rounded-full animate-spin" />
<span className="text-xs font-mono animate-pulse">
Loading game...
</span>
</div>
)}
Results
Before the overhaul, the 5 games existed but nobody saw them. After:
- +240% interaction in the games section (Umami)
- ~45s average time per game session
- Code Typing leads with 38% of plays
- Zero performance complaints (all vanilla or React production)
The portfolio became a real showcase — not only displaying code, but showing what that code can do.
What I learned
-
Invisible UX is the best UX. The games existed before but nobody knew. A carousel + cover + play button completely changed engagement.
-
A well-made iframe isn’t a hack. With a themed frame, loading state, and memory cleanup, the experience feels integrated.
-
API route as proxy is underrated. Serves static files with controlled headers, without needing an extra server.
-
Data speaks. Umami analytics showed clear patterns — code-typing is the favorite, asteroid-dodge has the highest average session time.
-
Mini-games aren’t just decoration. They’re the most interactive part of the portfolio. People spend more time playing than reading about skills.