| name | ta-ui-polish |
| description | UI and visual polish checklist for game presentation. Use when adding final polish, styling, animations, visual feedback. |
| category | ui |
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
Animation & Feedback
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';
if (ping < 100) return '#eab308';
return '#ef4444';
};
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
import { useEffect, useState } from 'react';
import { useConnectionStore } from '@/store/connectionStore';
export function ConnectionStatus() {
const { connected, connecting, roomId } = useConnectionStore();
const [ping, setPing] = useState(0);
useEffect(() => {
if (!connected) return;
const interval = setInterval(async () => {
const start = performance.now();
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
Performance Polish
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 = () => {
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
.text-xs { font-size: 0.75rem; }
.text-sm { font-size: 0.875rem; }
.text-base { font-size: 1rem; }
.text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; }
.text-2xl { font-size: 1.5rem; }
.text-3xl { font-size: 1.875rem; }
.text-4xl { font-size: 2.25rem; }
.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
.space-1 { padding: 0.25rem; }
.space-2 { padding: 0.5rem; }
.space-3 { padding: 0.75rem; }
.space-4 { padding: 1rem; }
.space-5 { padding: 1.25rem; }
.space-6 { padding: 1.5rem; }
.space-8 { padding: 2rem; }
.space-10 { padding: 2.5rem; }
.space-12 { padding: 3rem; }
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:
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
export class HUD extends Phaser.GameObjects.Container {
private stars: Phaser.GameObjects.Image[] = [];
private currentScore: number = 0;
updateStarRating(score: number, level: number): void {
const twoStarThreshold = 32000 + (level * 2000);
const threeStarThreshold = 66000 + (level * 6000);
const starCount = this.calculateStarCount(score, twoStarThreshold, threeStarThreshold);
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;
const targetAlpha = shouldFill ? 1.0 : 0.3;
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
},
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
-
Batch tween updates - Don't create tweens on every frame
update() {
this.star.setFillStyle(this.getColor(score));
}
private lastStarCount: number = -1;
update() {
const newCount = this.calculateStars(score);
if (newCount !== this.lastStarCount) {
this.animateStarChange(newCount);
this.lastStarCount = newCount;
}
}
-
Reuse tween targets - Destroy old tweens before creating new ones
updateStarFill(star: Phaser.GameObjects.Image): void {
this.scene.tweens.killTweensOf(star);
this.scene.tweens.add({
targets: star,
});
}
-
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.
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:
.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
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',
sm: '16px',
base: '18px',
lg: '24px',
xl: '32px',
'2xl': '48px',
'3xl': '64px',
display: '128px',
},
};
Metallic Button Design System
Inspired by: Quake III Arena, modern Call of Duty, Overwatch
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%
)`,
};
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
export const GameEasing = {
snap: [0.22, 1, 0.36, 1] as const,
reveal: [0.16, 1, 0.3, 1] as const,
bounce: [0.34, 1.56, 0.64, 1] as const,
press: [0.4, 0, 0.2, 1] as const,
};
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 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 {
--color-primary-500: #f97316;
--color-primary-600: #ea580c;
--color-secondary-500: #06b6d4;
--color-metal-900: #0a0a0a;
--color-metal-700: #262626;
--color-metal-500: #525252;
--color-surface-bg: rgba(10, 10, 10, 0.95);
--color-surface-border: rgba(251, 115, 22, 0.3);
--color-glow-primary: rgba(249, 115, 22, 0.6);
}
UI Design System Checklist
Before considering UI polished: