ta-ui-polish

UI and visual polish checklist for game presentation. Use when adding final polish, styling, animations, visual feedback.

SKILL.md

GitHub

Visual Polish Skill

"The last 10% of polish takes 90% of the time – but it's worth it."

When to Use This Skill

Use when:

  • Finalizing visual presentation
  • Creating UI elements and HUD
  • Adding feedback animations
  • Implementing transitions

Polish Checklist

Core Visuals

  • Lighting is balanced - No blown highlights, no crushed shadows
  • Colors match GDD palette - Consistent art direction
  • Materials look correct - PBR values appropriate for materials
  • Silhouette is readable - Objects identifiable from outline
  • Framing guides the eye - Important elements draw attention

Animation & Feedback

  • Buttons have hover states - Visual feedback on interaction
  • Transitions are smooth - No jarring cuts
  • Loading states exist - User knows something is happening
  • Error states are clear - Problems are visible and understandable
  • Success feedback exists - Positive reinforcement
  • Connection status visible - Multiplayer shows network state

Connection Status UI Pattern (Multiplayer)

CRITICAL for multiplayer games: Players need to see connection state and network quality.

States to Indicate

State Visual Indicator Color Animation
Connecting Spinner + "Connecting..." text Yellow Rotate
Connected Checkmark icon + ping in ms Green Pulse (subtle)
Disconnected X icon + "Disconnected" text Red Blink
Reconnecting Spinner + "Reconnecting..." Orange Rotate

Ping Quality Color Coding

function PingIndicator({ ping }: { ping: number }) {
  const getPingColor = (ping: number) => {
    if (ping < 50) return '#22c55e';  // Green
    if (ping < 100) return '#eab308'; // Yellow
    return '#ef4444';                  // Red
  };

  const getPingStatus = (ping: number) => {
    if (ping < 50) return 'Excellent';
    if (ping < 100) return 'Fair';
    return 'Poor';
  };

  return (
    <div className="ping-indicator" style={{ color: getPingColor(ping) }}>
      <WifiIcon className={`animate-pulse ${ping > 100 ? 'animate-ping' : ''}`} />
      <span>{ping}ms</span>
      <span className="status">{getPingStatus(ping)}</span>
    </div>
  );
}

Implementation Example

// ConnectionStatus.tsx
import { useEffect, useState } from 'react';
import { useConnectionStore } from '@/store/connectionStore';

export function ConnectionStatus() {
  const { connected, connecting, roomId } = useConnectionStore();
  const [ping, setPing] = useState(0);

  // Measure ping every second
  useEffect(() => {
    if (!connected) return;

    const interval = setInterval(async () => {
      const start = performance.now();
      // Send ping to server and await response
      await fetch('/api/ping');
      setPing(Math.round(performance.now() - start));
    }, 1000);

    return () => clearInterval(interval);
  }, [connected]);

  if (connecting) {
    return (
      <div className="connection-status connecting">
        <Spinner className="animate-spin" />
        <span>Connecting...</span>
      </div>
    );
  }

  if (!connected) {
    return (
      <div className="connection-status disconnected">
        <XIcon className="animate-pulse" />
        <span>Disconnected</span>
      </div>
    );
  }

  const pingColor = ping < 50 ? 'green' : ping < 100 ? 'yellow' : 'red';

  return (
    <div className="connection-status connected">
      <CheckIcon className="text-green-500" />
      <span>Connected</span>
      <span className={`ping ping-${pingColor}`}>{ping}ms</span>
    </div>
  );
}

Placement in HUD

┌─────────────────────────────────────────────────────┐
│                              [Connection: ● 45ms] │
│  Health: 100█████████████                           │
│  Armor:  50█████                                    │
│                                  [Alive: 42/64]     │
└─────────────────────────────────────────────────────┘

Icon Patterns

Icon When to Use Styling
Spinner Connecting, Reconnecting animate-spin rotation
Checkmark Connected Green, subtle pulse
X mark Disconnected Red, animate-ping blink
Warning High ping (>100ms) Yellow, animate-pulse
Wifi bars Signal strength 3-4 bars based on ping

UI Presentation

  • Typography is readable - Appropriate sizes, weights, line heights
  • Contrast meets accessibility - WCAG AA minimum (4.5:1 for text)
  • Spacing is consistent - Grid/spacing system used
  • Alignment is intentional - Elements line up properly
  • Hierarchy is clear - Most important elements stand out

Performance Polish

  • No visible frame drops - 60 FPS maintained
  • Loading times are acceptable - Assets optimized
  • Memory usage is stable - No leaks or growing allocation
  • Mobile tested - Works on target mobile devices

UI Component Polish Template

import { useState } from 'react';
import { html } from '@react-three/drei';
import { motion, AnimatePresence } from 'framer-motion';

interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
  loading?: boolean;
}

export function PolishedButton({
  label,
  onClick,
  variant = 'primary',
  disabled = false,
  loading = false,
}: ButtonProps) {
  const [isHovered, setIsHovered] = useState(false);

  const baseStyles = {
    padding: '12px 24px',
    borderRadius: '8px',
    fontWeight: 600,
    fontSize: '16px',
    border: 'none',
    cursor: disabled ? 'not-allowed' : 'pointer',
    transition: 'all 0.2s ease',
    opacity: disabled ? 0.5 : 1,
  };

  const variantStyles = {
    primary: {
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      color: 'white',
      boxShadow: isHovered ? '0 8px 20px rgba(102, 126, 234, 0.4)' : '0 4px 10px rgba(102, 126, 234, 0.3)',
      transform: isHovered ? 'translateY(-2px)' : 'translateY(0)',
    },
    secondary: {
      background: 'white',
      color: '#667eea',
      border: '2px solid #667eea',
      boxShadow: isHovered ? '0 4px 15px rgba(102, 126, 234, 0.2)' : 'none',
    },
    danger: {
      background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
      color: 'white',
      boxShadow: isHovered ? '0 8px 20px rgba(245, 87, 108, 0.4)' : '0 4px 10px rgba(245, 87, 108, 0.3)',
    },
  };

  return (
    <motion.button
      style={{ ...baseStyles, ...variantStyles[variant] }}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onClick={!disabled && !loading ? onClick : undefined}
      whileTap={{ scale: disabled || loading ? 1 : 0.95 }}
      disabled={disabled || loading}
    >
      <AnimatePresence mode="wait">
        {loading ? (
          <motion.span
            key="loading"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            <LoadingSpinner />
          </motion.span>
        ) : (
          <motion.span
            key="label"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            {label}
          </motion.span>
        )}
      </AnimatePresence>
    </motion.button>
  );
}

Visual Feedback Patterns

Hover Feedback

<mesh
  onPointerOver={(e) => {
    e.object.scale.setScalar(1.1);
    document.body.style.cursor = 'pointer';
  }}
  onPointerOut={(e) => {
    e.object.scale.setScalar(1.0);
    document.body.style.cursor = 'default';
  }}
>
  <boxGeometry />
  <meshStandardMaterial color="orange" />
</mesh>

Click Feedback

function InteractiveMesh() {
  const meshRef = useRef<THREE.Mesh>(null);

  const handleClick = () => {
    // Scale animation
    if (meshRef.current) {
      gsap.to(meshRef.current.scale, {
        x: 1.2,
        y: 1.2,
        z: 1.2,
        duration: 0.1,
        yoyo: true,
        repeat: 1,
      });
    }
  };

  return (
    <mesh ref={meshRef} onClick={handleClick}>
      <sphereGeometry />
      <meshStandardMaterial color="blue" />
    </mesh>
  );
}

Progress Feedback

function ProgressBar({ progress }: { progress: number }) {
  return (
    <div className="progress-container">
      <div
        className="progress-bar"
        style={{
          width: `${Math.min(100, Math.max(0, progress * 100))}%`,
          transition: 'width 0.3s ease',
        }}
      />
    </div>
  );
}

Color Guidelines

Accessible Color Combinations

Background Text Ratio WCAG Level
#FFFFFF #000000 21:1 AAA
#F5F5F5 #333333 12.6:1 AAA
#667EEA #FFFFFF 4.5:1 AA
#F5576C #FFFFFF 4.2:1 AA

Common Mistakes

  • ❌ Red/green as only differentiators (colorblindness)
  • ❌ Light text on light background
  • ❌ Pure colors (#FF0000, #00FF00) - too harsh
  • ❌ Too many colors in one view

Typography Guidelines

/* Font scale - modular scale */
.text-xs { font-size: 0.75rem; }    /* 12px */
.text-sm { font-size: 0.875rem; }   /* 14px */
.text-base { font-size: 1rem; }     /* 16px */
.text-lg { font-size: 1.125rem; }   /* 18px */
.text-xl { font-size: 1.25rem; }    /* 20px */
.text-2xl { font-size: 1.5rem; }    /* 24px */
.text-3xl { font-size: 1.875rem; }  /* 30px */
.text-4xl { font-size: 2.25rem; }   /* 36px */

/* Weights */
.font-light { font-weight: 300; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }

Spacing System

/* 8px base unit */
.space-1 { padding: 0.25rem; }  /* 4px */
.space-2 { padding: 0.5rem; }   /* 8px */
.space-3 { padding: 0.75rem; }  /* 12px */
.space-4 { padding: 1rem; }     /* 16px */
.space-5 { padding: 1.25rem; }  /* 20px */
.space-6 { padding: 1.5rem; }   /* 24px */
.space-8 { padding: 2rem; }     /* 32px */
.space-10 { padding: 2.5rem; }  /* 40px */
.space-12 { padding: 3rem; }    /* 48px */

Anti-Patterns

❌ DON'T:

  • Use placeholder colors (magenta, lime green)
  • Skip hover states on interactive elements
  • Use too many fonts (>2 typefaces)
  • Inconsistent spacing
  • No feedback for user actions
  • Text on busy backgrounds without contrast

✅ DO:

  • Use colors from GDD palette
  • Provide visual feedback for all interactions
  • Limit fonts to 1-2 typefaces
  • Follow spacing system consistently
  • Test for color blindness
  • Ensure text readability

Checklist

Before considering visual polish complete:

  • All interactive elements have hover/click states
  • Loading states exist for async operations
  • Error messages are clear and actionable
  • Success feedback is provided
  • Colors meet accessibility standards
  • Typography is consistent and readable
  • Spacing follows system
  • Transitions are smooth (200-300ms)
  • Performance tested on target devices
  • Visual style matches GDD

Related Skills

For post-processing polish: Skill("ta-vfx-postfx")

Phaser UI Animation Patterns (feat-027)

Lesson from feat-027 (Star Rating Preview): Phaser UI animations use tweens with real-time data updates.

Real-time UI Updates Pattern

// src/ui/HUD.ts - Star rating preview with live score tracking
export class HUD extends Phaser.GameObjects.Container {
  private stars: Phaser.GameObjects.Image[] = [];
  private currentScore: number = 0;

  updateStarRating(score: number, level: number): void {
    // Calculate thresholds per DEC-004: 2-star = 32,000 + (level × 2,000)
    const twoStarThreshold = 32000 + (level * 2000);
    const threeStarThreshold = 66000 + (level * 6000);

    // Determine star count from current score
    const starCount = this.calculateStarCount(score, twoStarThreshold, threeStarThreshold);

    // Update each star's visual state
    for (let i = 0; i < 3; i++) {
      const shouldFill = i < starCount;
      this.updateStarFill(this.stars[i], shouldFill);
    }
  }

  private updateStarFill(star: Phaser.GameObjects.Image, shouldFill: boolean): void {
    const targetColor = shouldFill ? 0xffcc00 : 0xcccccc; // Yellow or gray
    const targetAlpha = shouldFill ? 1.0 : 0.3;

    // Smooth tween for color interpolation
    this.scene.tweens.add({
      targets: star,
      duration: 300,
      ease: Phaser.Math.Easing.Quadratic.Out,
      props: {
        tint: targetColor,
        alpha: targetAlpha,
        scale: shouldFill ? 1.1 : 1.0 // Subtle pulse on fill
      },
      yoyo: false,
      repeat: 0
    });
  }
}

Key Animation Best Practices

Technique When to Use Duration Easing
Color interpolation Status changes (filled/empty) 300ms Quadratic.Out
Scale pulse Highlight/attention effect 200ms Sine.easeInOut
Fade in/out Element reveal 400ms Cubic.Out
Slide Position transitions 250ms Back.Out

Performance Considerations

  1. Batch tween updates - Don't create tweens on every frame

    // WRONG - Tweens on every update call
    update() {
      this.star.setFillStyle(this.getColor(score)); // Creates new tween each frame
    }
    
    // RIGHT - Only tween on state change
    private lastStarCount: number = -1;
    update() {
      const newCount = this.calculateStars(score);
      if (newCount !== this.lastStarCount) {
        this.animateStarChange(newCount); // Only tween when changed
        this.lastStarCount = newCount;
      }
    }
    
  2. Reuse tween targets - Destroy old tweens before creating new ones

    // Prevent tween accumulation
    updateStarFill(star: Phaser.GameObjects.Image): void {
      // Kill existing tweens on this object
      this.scene.tweens.killTweensOf(star);
    
      // Create new tween
      this.scene.tweens.add({
        targets: star,
        // ... config
      });
    }
    
  3. Use appropriate easing for UI

    • Quadratic.Out - Fast start, smooth end (good for fills)
    • Cubic.Out - Smooth throughout (good for transitions)
    • Sine.easeInOut - Gentle in/out (good for pulses)
    • Back.Out - Subtle overshoot (good for button clicks)

External References


Professional Game UI Design System (Added: ui-001 Playtest Findings)

Learned from ui-001 playtest: Current UI lacked professional polish. This section documents patterns for shipping-quality game UI.

16:9 Aspect Ratio Enforcement

Pattern: All UI screens must maintain 16:9 aspect ratio, centered on screen with letterbox bars for other ratios.

// src/components/ui/AspectContainer.tsx
const ASPECT_RATIO = 16 / 9;

export function AspectContainer({ children }: { children: React.ReactNode }) {
  return (
    <div className="fixed inset-0 z-10 flex items-center justify-center bg-black">
      <div
        className="relative overflow-hidden"
        style={{
          aspectRatio: ASPECT_RATIO,
          maxWidth: '100vw',
          maxHeight: '100vh',
          width: 'min(100vw, 100vh * 16/9)',
          height: 'min(100vh, 100vw * 9/16)',
        }}
      >
        {children}
      </div>
    </div>
  );
}

Letterbox CSS:

/* Letterbox bars for non-16:9 displays */
.ui-letterbox-top,
.ui-letterbox-bottom {
  position: fixed;
  left: 0; right: 0;
  height: calc((100vh - (100vw * 9 / 16)) / 2);
  background: #000;
  z-index: 50;
}
.ui-letterbox-top { top: 0; }
.ui-letterbox-bottom { bottom: 0; }

@media (min-aspect-ratio: 16/9) {
  .ui-letterbox-top, .ui-letterbox-bottom { display: none; }
}

Adaptive Scaling Tokens

// src/components/ui/tokens.ts
export const UIScale = {
  base: { width: 1920, height: 1080 },

  getScaleFactor(windowWidth: number, windowHeight: number): number {
    const baseWidth = 1920;
    const scale = Math.min(
      windowWidth / baseWidth,
      windowHeight / (baseWidth * 9 / 16)
    );
    return Math.max(0.5, Math.min(2, scale));
  },

  fontSize: {
    xs: '14px',   // 0.875rem
    sm: '16px',   // 1rem
    base: '18px', // 1.125rem
    lg: '24px',   // 1.5rem
    xl: '32px',   // 2rem
    '2xl': '48px', // 3rem
    '3xl': '64px', // 4rem
    display: '128px', // 8rem
  },
};

Metallic Button Design System

Inspired by: Quake III Arena, modern Call of Duty, Overwatch

// Metallic gradient backgrounds
const metallicBackgrounds = {
  primary: `linear-gradient(180deg,
    rgba(249, 115, 22, 0.1) 0%,
    rgba(0, 0, 0, 0.9) 20%,
    rgba(0, 0, 0, 1) 50%,
    rgba(0, 0, 0, 0.9) 80%,
    rgba(249, 115, 22, 0.1) 100%
  )`,

  secondary: `linear-gradient(180deg,
    rgba(38, 38, 38, 0.9) 0%,
    rgba(10, 10, 10, 1) 50%,
    rgba(38, 38, 38, 0.9) 100%
  )`,
};

// Hover glow effects
const metallicGlow = {
  hover: `box-shadow:
    0 0 20px rgba(251, 115, 22, 0.4),
    inset 0 0 20px rgba(251, 115, 22, 0.1)`,

  active: `box-shadow:
    0 0 40px rgba(251, 115, 22, 0.6),
    inset 0 0 30px rgba(251, 115, 22, 0.2)`,
};

Custom Easing Curves for Game UI

Sources: Motion.dev Easing, The Easing Blueprint

// Easing curve library for game UI
export const GameEasing = {
  // Snappy entrance (menu reveal)
  snap: [0.22, 1, 0.36, 1] as const,

  // Smooth reveal (screen transitions)
  reveal: [0.16, 1, 0.3, 1] as const,

  // Elastic bounce (button release)
  bounce: [0.34, 1.56, 0.64, 1] as const,

  // Weighty press (button click)
  press: [0.4, 0, 0.2, 1] as const,
};

// Animation timing specifications
const buttonAnimations = {
  idleEnter: { duration: 400, ease: GameEasing.snap },
  hover: { duration: 150, ease: GameEasing.press },
  active: { duration: 50, ease: GameEasing.press },
  release: { duration: 300, ease: GameEasing.bounce },
};

Gaming Typography

/* Import Google Fonts for game UI */
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700;900&family=Rajdhani:wght@500;600;700&display=swap');

:root {
  --font-display: 'Orbitron', 'Impact', sans-serif;
  --font-ui: 'Rajdhani', 'Segoe UI', sans-serif;
  --font-mono: 'JetBrains Mono', 'Courier New', monospace;
}
Usage Font Size Weight Letter Spacing Transform
Game Logo Orbitron 128px 900 0.05em Uppercase
Screen Title Orbitron 64px 700 0.1em Uppercase
Button Text Rajdhani 24px 600 0.15em Uppercase
Body Text Rajdhani 18px 500 0.02em None
Data/Stats JetBrains Mono 16px 500 0 None

Industrial Color Palette

:root {
  /* Primary - Electric Orange (Quake-inspired) */
  --color-primary-500: #f97316;
  --color-primary-600: #ea580c;

  /* Secondary - Cyan/Blue */
  --color-secondary-500: #06b6d4;

  /* Metallic Grays */
  --color-metal-900: #0a0a0a;
  --color-metal-700: #262626;
  --color-metal-500: #525252;

  /* Surface Colors */
  --color-surface-bg: rgba(10, 10, 10, 0.95);
  --color-surface-border: rgba(251, 115, 22, 0.3);

  /* Glow Effects */
  --color-glow-primary: rgba(249, 115, 22, 0.6);
}

UI Design System Checklist

Before considering UI polished:

  • 16:9 Enforcement - All screens maintain aspect ratio
  • Adaptive Scaling - UI scales to any window size
  • Design Tokens - Colors, typography, spacing systematized
  • Metallic Buttons - Industrial styling with gradients
  • Custom Easing - Game-appropriate animation curves
  • Gaming Fonts - Orbitron/Rajdhani or similar
  • Hover Glow - Visual feedback on interaction
  • Sound Feedback - Optional but recommended
  • 60+ FPS - Animations maintain performance
  • E2E Tests - Visual regression tests pass