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