5 improved games — the day the portfolio became a showcase
Portfolio·

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: requestAnimationFrame instead 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

  1. Invisible UX is the best UX. The games existed before but nobody knew. A carousel + cover + play button completely changed engagement.

  2. A well-made iframe isn’t a hack. With a themed frame, loading state, and memory cleanup, the experience feels integrated.

  3. API route as proxy is underrated. Serves static files with controlled headers, without needing an extra server.

  4. Data speaks. Umami analytics showed clear patterns — code-typing is the favorite, asteroid-dodge has the highest average session time.

  5. Mini-games aren’t just decoration. They’re the most interactive part of the portfolio. People spend more time playing than reading about skills.


~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$