board-game-ui
Board Game UI Skill
Overview
This skill provides expertise for building user interfaces for digital board games. It covers rendering approaches (DOM, Canvas, SVG), interaction patterns (drag-and-drop, click-to-select), responsive design for different screen sizes, and UX principles specific to turn-based games.
Rendering Approaches
When to Use Each
| Approach | Best For | Avoid When |
|---|---|---|
| DOM/CSS | Card games, simple boards, UI overlays | Many moving pieces, complex animations |
| SVG | Maps, vector graphics, zoomable boards | Thousands of elements, pixel effects |
| Canvas | Complex animations, particles, real-time | Accessibility needed, text-heavy |
| Hybrid | Most board games | Over-engineering simple games |
Recommended: Hybrid Approach
Use DOM for UI chrome (menus, player info, cards) and Canvas/SVG for the game board:
<div class="game-container">
<!-- DOM: Player info, always visible -->
<aside class="player-panel">
<div class="player-info" data-player="1">...</div>
</aside>
<!-- SVG: The game board/map -->
<main class="board-area">
<svg id="game-board" viewBox="0 0 1000 800">
<!-- Routes, cities, tokens -->
</svg>
</main>
<!-- DOM: Action buttons, cards in hand -->
<footer class="action-bar">
<div class="hand-cards">...</div>
<div class="action-buttons">...</div>
</footer>
</div>
Layout Patterns
Responsive Game Layout
.game-container {
display: grid;
height: 100vh;
gap: 1rem;
padding: 1rem;
/* Desktop: sidebar layout */
grid-template-columns: 250px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas:
"sidebar board"
"sidebar actions";
}
/* Tablet: stack sidebar above */
@media (max-width: 1024px) {
.game-container {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"sidebar"
"board"
"actions";
}
.player-panel {
display: flex;
overflow-x: auto;
}
}
/* Mobile: minimal chrome */
@media (max-width: 600px) {
.game-container {
padding: 0.5rem;
gap: 0.5rem;
}
.player-panel {
font-size: 0.875rem;
}
}
Aspect Ratio Preservation
.board-area {
grid-area: board;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#game-board {
max-width: 100%;
max-height: 100%;
aspect-ratio: 5 / 4; /* Match your board dimensions */
}
SVG Game Board
Board Structure
<svg id="game-board" viewBox="0 0 1000 800">
<!-- Background layer -->
<g id="background">
<image href="/assets/map-age-1.jpg" width="1000" height="800" />
</g>
<!-- Routes layer -->
<g id="routes">
<path class="route" data-route-id="london-paris"
d="M 200,150 Q 250,200 350,180"
stroke="#666" stroke-width="8" fill="none" />
</g>
<!-- Cities layer -->
<g id="cities">
<g class="city" data-city-id="london" transform="translate(200, 150)">
<circle r="20" fill="#333" />
<text y="35" text-anchor="middle">London</text>
</g>
</g>
<!-- Tokens layer (on top) -->
<g id="tokens">
<g class="ship-token" data-player="1" data-ship-id="ship-1"
transform="translate(200, 150)">
<use href="#airship-icon" fill="var(--player-1-color)" />
</g>
</g>
<!-- Definitions -->
<defs>
<symbol id="airship-icon" viewBox="0 0 40 20">
<ellipse cx="20" cy="10" rx="18" ry="8" />
<rect x="8" y="14" width="24" height="4" rx="2" />
</symbol>
</defs>
</svg>
Interactive Elements
// Click handling on SVG elements
document.getElementById('game-board').addEventListener('click', (e) => {
const route = e.target.closest('.route');
const city = e.target.closest('.city');
const token = e.target.closest('.ship-token');
if (route) {
handleRouteClick(route.dataset.routeId);
} else if (city) {
handleCityClick(city.dataset.cityId);
} else if (token) {
handleTokenClick(token.dataset.shipId);
}
});
// Hover effects
function setupHoverEffects() {
document.querySelectorAll('.route').forEach(route => {
route.addEventListener('mouseenter', () => {
route.classList.add('highlighted');
showRouteTooltip(route.dataset.routeId);
});
route.addEventListener('mouseleave', () => {
route.classList.remove('highlighted');
hideTooltip();
});
});
}
/* SVG hover/selection states */
.route {
cursor: pointer;
transition: stroke 0.2s, stroke-width 0.2s;
}
.route:hover,
.route.highlighted {
stroke: #4a9eff;
stroke-width: 12;
}
.route.claimed {
stroke: var(--claiming-player-color);
}
.route.unavailable {
opacity: 0.3;
pointer-events: none;
}
.city:hover circle {
fill: #555;
transform: scale(1.1);
}
Drag and Drop
HTML5 Drag and Drop (for cards/tiles)
// Make elements draggable
function setupDraggable(element, type, id) {
element.draggable = true;
element.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/json', JSON.stringify({ type, id }));
e.dataTransfer.effectAllowed = 'move';
element.classList.add('dragging');
});
element.addEventListener('dragend', () => {
element.classList.remove('dragging');
clearDropTargets();
});
}
// Make slots accept drops
function setupDropTarget(element, acceptTypes, onDrop) {
element.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
element.classList.add('drop-target');
});
element.addEventListener('dragleave', () => {
element.classList.remove('drop-target');
});
element.addEventListener('drop', (e) => {
e.preventDefault();
element.classList.remove('drop-target');
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (acceptTypes.includes(data.type)) {
onDrop(data);
}
});
}
Click-to-Select Alternative
For touch devices and accessibility:
let selectedItem = null;
function handleItemClick(item) {
if (selectedItem === item) {
// Deselect
deselectItem();
} else if (selectedItem) {
// Try to place selected item
if (canPlaceAt(selectedItem, item)) {
placeItem(selectedItem, item);
}
deselectItem();
} else {
// Select this item
selectItem(item);
}
}
function selectItem(item) {
selectedItem = item;
item.classList.add('selected');
highlightValidTargets(item);
}
function deselectItem() {
if (selectedItem) {
selectedItem.classList.remove('selected');
clearHighlights();
selectedItem = null;
}
}
Player Board UI
Blueprint Slot Grid
<div class="blueprint">
<div class="blueprint-grid">
<!-- Frame slots -->
<div class="slot frame-slot" data-slot-type="frame" data-slot-id="frame-1">
<div class="slot-label">Frame</div>
<div class="slot-content">
<!-- Upgrade tile goes here when installed -->
</div>
<div class="gas-socket">
<!-- Gas cube indicator -->
</div>
</div>
<!-- Drive slots -->
<div class="slot drive-slot" data-slot-type="drive" data-slot-id="drive-1">
<div class="slot-label">Drive</div>
<div class="slot-content empty"></div>
</div>
<!-- More slots... -->
</div>
<div class="blueprint-stats">
<div class="stat">
<span class="stat-icon">⚖️</span>
<span class="stat-value" data-stat="weight">12</span>
</div>
<div class="stat">
<span class="stat-icon">🎈</span>
<span class="stat-value" data-stat="lift">15</span>
</div>
<!-- More stats -->
</div>
</div>
.blueprint-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding: 1rem;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 8px;
}
.slot {
aspect-ratio: 1;
border: 2px dashed #444;
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: border-color 0.2s, background 0.2s;
}
.slot.empty:hover {
border-color: #4a9eff;
background: rgba(74, 158, 255, 0.1);
}
.slot.filled {
border-style: solid;
border-color: #666;
}
.slot.drop-target {
border-color: #4aff4a;
background: rgba(74, 255, 74, 0.1);
}
Card Hand Display
<div class="hand-container">
<div class="hand" id="player-hand">
<!-- Cards fan out -->
</div>
</div>
.hand-container {
perspective: 1000px;
padding: 1rem;
}
.hand {
display: flex;
justify-content: center;
}
.card {
width: 120px;
height: 180px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
margin-left: -40px; /* Overlap */
transition: transform 0.2s, margin 0.2s;
cursor: pointer;
}
.card:first-child {
margin-left: 0;
}
.card:hover {
transform: translateY(-20px) scale(1.05);
margin-left: 0;
margin-right: 40px;
z-index: 10;
}
.card.selected {
transform: translateY(-30px);
box-shadow: 0 0 20px rgba(74, 158, 255, 0.5);
}
Animations
Token Movement
function animateTokenMove(tokenElement, fromPos, toPos, duration = 500) {
return new Promise(resolve => {
// Calculate path
const dx = toPos.x - fromPos.x;
const dy = toPos.y - fromPos.y;
// Use CSS animation
tokenElement.style.transition = `transform ${duration}ms ease-out`;
tokenElement.style.transform = `translate(${dx}px, ${dy}px)`;
setTimeout(() => {
// After animation, update actual position
tokenElement.style.transition = '';
tokenElement.style.transform = '';
tokenElement.setAttribute('transform', `translate(${toPos.x}, ${toPos.y})`);
resolve();
}, duration);
});
}
State Change Animations
/* Highlight changes */
@keyframes pulse-highlight {
0%, 100% { box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.5); }
50% { box-shadow: 0 0 0 10px rgba(74, 158, 255, 0); }
}
.stat-value.changed {
animation: pulse-highlight 0.5s ease-out;
}
/* Income animation */
@keyframes float-up {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-30px); }
}
.income-popup {
animation: float-up 1s ease-out forwards;
color: #4aff4a;
font-weight: bold;
}
Turn Indicator
<div class="turn-indicator">
<div class="current-player">
<div class="player-avatar" style="--player-color: var(--player-1-color)"></div>
<span class="player-name">Germany</span>
</div>
<div class="turn-timer" id="turn-timer">
<svg class="timer-ring" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="16" fill="none" stroke="#333" stroke-width="3" />
<circle class="timer-progress" cx="18" cy="18" r="16"
fill="none" stroke="#4a9eff" stroke-width="3"
stroke-dasharray="100" stroke-dashoffset="0" />
</svg>
<span class="timer-text">60</span>
</div>
</div>
function startTurnTimer(duration) {
const timerText = document.querySelector('.timer-text');
const timerProgress = document.querySelector('.timer-progress');
let remaining = duration;
const interval = setInterval(() => {
remaining--;
timerText.textContent = remaining;
const percent = (remaining / duration) * 100;
timerProgress.style.strokeDashoffset = 100 - percent;
if (remaining <= 0) {
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
}
Tooltips and Info Panels
// Tooltip system
const tooltip = document.getElementById('tooltip');
function showTooltip(content, x, y) {
tooltip.innerHTML = content;
tooltip.style.left = `${x + 10}px`;
tooltip.style.top = `${y + 10}px`;
tooltip.classList.add('visible');
}
function hideTooltip() {
tooltip.classList.remove('visible');
}
// Rich tooltips for game elements
function getTechnologyTooltip(techId) {
const tech = technologies[techId];
return `
<div class="tooltip-tech">
<h4>${tech.name}</h4>
<p class="tooltip-cost">Cost: ${tech.cost} Research</p>
<p class="tooltip-desc">${tech.description}</p>
<p class="tooltip-unlocks">Unlocks: ${tech.unlocks}</p>
</div>
`;
}
Accessibility
Keyboard Navigation
// Make game elements keyboard accessible
document.querySelectorAll('.slot, .card, .route').forEach(el => {
el.setAttribute('tabindex', '0');
el.setAttribute('role', 'button');
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
el.click();
}
});
});
Screen Reader Support
<!-- Announce game state changes -->
<div id="game-announcer" aria-live="polite" class="sr-only"></div>
<script>
function announce(message) {
document.getElementById('game-announcer').textContent = message;
}
// Usage
announce("Germany's turn. They placed a worker at the Construction Hall.");
</script>
Performance Tips
- Batch DOM updates - Use DocumentFragment or requestAnimationFrame
- Virtual scrolling - For long lists (action log, card library)
- Lazy load assets - Load board images for other Ages on demand
- Debounce resize handlers - Don't recalculate layout on every pixel
- Use CSS transforms - For animations instead of top/left
- Layer with z-index - Keep frequently-updated elements on separate layers
When This Skill Activates
Use this skill when:
- Building game board rendering
- Implementing drag-and-drop interactions
- Designing player board layouts
- Creating card hand displays
- Adding animations and transitions
- Making responsive game layouts
- Improving game accessibility
More from fil512/upship
ui-design-expert
Expert UI design guidance for polishing game interfaces. Use when reviewing screenshots, recommending CSS improvements, designing color schemes, improving typography, adding visual polish, or making the game look professional. Specializes in board game aesthetics with a steampunk/brass era theme.
12boardgame-design
Board game design workflow for creating fun, balanced games. Use when designing mechanics, balancing factions, analyzing resource economies, validating rules clarity, planning playtests, or iterating on game systems. Applies Eurogame principles and proven design methodology.
11realtime-multiplayer
Real-time multiplayer game networking with Socket.io. Use when implementing WebSocket connections, game state synchronization, room management, reconnection handling, or optimistic updates. Covers latency compensation and conflict resolution.
8svelte
Modern Svelte development for reactive web apps. Use when building Svelte components, managing state with stores, implementing real-time updates via WebSocket, or migrating from vanilla JS. Covers SvelteKit, TypeScript, and integration with Node.js backends.
4game-state
Game state management for turn-based board games. Use when designing state structure, implementing game logic, validating actions, managing phases/turns, or handling complex game rules. Covers reducers, state machines, and undo/redo.
4rulebook-writing
Expert guidance for writing UP SHIP! rulebook content following Euro-style board game best practices. Use when drafting rules sections, reviewing rules clarity, restructuring content, or ensuring consistency. Covers section ordering, terminology, formatting, cross-referencing, and audience design.
4