Skip to content
SiteEmail

Subtitles and closed captions are built in HTML and CSS like any other UI component, but they require three things to work well: accurate timing sync from the game engine, player-controlled style customization (size, color, background), and a positioning system that adapts to screen layout and player preference. This article covers all three.

These terms are often used interchangeably, but the distinction matters for scope:

  • Subtitles transcribe spoken dialogue. They assume the player can hear ambient sound and music but needs the dialogue in text form (for language barriers, noisy environments, or personal preference).
  • Closed Captions transcribe all audio: dialogue, sound effects, and music cues. They are designed for players who cannot hear any audio.

A well-built system should support both modes. The HTML structure is identical; the difference is in what text content the game engine sends.


The caption container lives at the root of your HUD, positioned independently of other UI panels. It receives and displays one or more caption entries at a time:

CaptionContainer.tsx
import { createSignal, For } from 'solid-js';
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';
type CaptionEntry = { speaker?: string; text: string };
const [captions, setCaptions] = createSignal<CaptionEntry[]>([]);
const CaptionContainer = () => (
<Block
id="caption-container"
class="caption-container"
attr:aria-live="polite"
attr:aria-atomic="false"
>
<For each={captions()}>
{(entry) => (
<Block class="caption-entry">
{entry.speaker && (
<InlineTextBlock class="caption-speaker">{entry.speaker}</InlineTextBlock>
)}
<TextBlock class="caption-text">{entry.text}</TextBlock>
</Block>
)}
</For>
</Block>
);

The aria-live="polite" attribute announces new caption text through TTS for players using screen readers (useful if a player uses both captions and TTS simultaneously). aria-atomic="false" lets screen readers announce each new entry as it arrives rather than re-reading the full container.

A single caption entry looks like this:

import Block from '@components/Layout/Block/Block';
import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const CaptionEntry = (props: { speaker: string; text: string }) => (
<Block class="caption-entry">
<InlineTextBlock class="caption-speaker">{props.speaker}</InlineTextBlock>
<TextBlock class="caption-text">{props.text}</TextBlock>
</Block>
);

The speaker label is optional but recommended for scenes with multiple characters. It allows players to follow conversations at a glance.


The container should anchor to the bottom center of the viewport by default, which is the convention players expect from years of film and game subtitle standards:

:root {
--caption-bg: rgba(0, 0, 0, 0.75);
--caption-color: #ffffff;
--caption-font-size: 1rem;
--caption-font-family: inherit;
--caption-speaker-color: #ffd700;
--caption-sfx-color: #cccccc;
}
.caption-container {
position: fixed;
bottom: 8%;
left: 50%;
transform: translateX(-50%);
width: 70%;
max-width: 900px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
pointer-events: none;
z-index: 1000;
}
.caption-entry {
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--caption-bg);
color: var(--caption-color);
font-size: var(--caption-font-size);
font-family: var(--caption-font-family);
padding: 0.4em 0.8em;
border-radius: 4px;
line-height: 1.4;
max-width: 100%;
text-align: center;
}
.caption-speaker {
font-size: 0.75em;
font-weight: 600;
color: var(--caption-speaker-color);
margin-bottom: 0.2em;
text-transform: uppercase;
letter-spacing: 0.05em;
}

The default values for all caption custom properties are declared on :root at the top of the stylesheet. The CSS custom properties on .caption-entry--caption-bg, --caption-color, --caption-font-size — are the player customization hooks. Every style the player can adjust goes through a custom property rather than a direct value.


The game engine drives caption timing. Your UI subscribes to engine events and renders caption entries on demand:

const captionContainer = document.getElementById('caption-container');
engine.on('ShowCaption', (data) => {
displayCaption(data.speaker, data.text, data.duration);
});
engine.on('ClearCaptions', () => {
captionContainer.innerHTML = '';
});

The ShowCaption event carries the speaker name, text content, and display duration in milliseconds. The ClearCaptions event wipes the container when a scene ends or dialogue pauses.

The display function creates and manages entries:

function displayCaption(speaker, text, duration) {
const entry = document.createElement('div');
entry.className = 'caption-entry';
if (speaker) {
const speakerEl = document.createElement('span');
speakerEl.className = 'caption-speaker';
speakerEl.textContent = speaker;
entry.appendChild(speakerEl);
}
const textEl = document.createElement('span');
textEl.className = 'caption-text';
textEl.textContent = text;
entry.appendChild(textEl);
captionContainer.appendChild(entry);
// Auto-remove after duration (engine may also send ClearCaptions explicitly)
if (duration > 0) {
setTimeout(() => {
if (entry.parentNode === captionContainer) {
captionContainer.removeChild(entry);
}
}, duration);
}
}

The guard in the setTimeout callback (entry.parentNode === captionContainer) prevents errors when ClearCaptions removes the container contents before the timer fires.


Caption preferences should live in the accessibility section of the settings menu. Four controls cover the full standard:

  1. Font size (small / medium / large / extra large)
  2. Text color (white, yellow, cyan, green)
  3. Background opacity (none, low, medium, high)
  4. Screen position (bottom, top)

Each setting maps to a CSS custom property update on the <html> element or the caption container itself:

const CAPTION_FONT_SIZES = {
small: '0.75rem',
medium: '1rem',
large: '1.375rem',
xlarge: '1.75rem',
};
const CAPTION_BG_OPACITIES = {
none: 'rgba(0, 0, 0, 0)',
low: 'rgba(0, 0, 0, 0.4)',
medium: 'rgba(0, 0, 0, 0.7)',
high: 'rgba(0, 0, 0, 0.92)',
};
function applyCaptionSettings(settings) {
const root = document.documentElement;
root.style.setProperty('--caption-font-size', CAPTION_FONT_SIZES[settings.fontSize] ?? '1rem');
root.style.setProperty('--caption-color', settings.textColor ?? '#ffffff');
root.style.setProperty('--caption-bg', CAPTION_BG_OPACITIES[settings.bgOpacity] ?? 'rgba(0,0,0,0.75)');
const container = document.getElementById('caption-container');
container.style.bottom = settings.position === 'top' ? 'auto' : '8%';
container.style.top = settings.position === 'top' ? '8%' : 'auto';
}
engine.on('CaptionSettingsChanged', (settings) => {
applyCaptionSettings(settings);
});

Apply the saved settings during UI initialization so the player’s preferences are honored from the first moment captions appear:

const savedCaptionSettings = engine.call('GetCaptionSettings') ?? {};
applyCaptionSettings(savedCaptionSettings);

Closed caption mode adds non-dialogue audio descriptions. The convention is to wrap these in square brackets and style them differently from speech:

<div class="caption-entry caption-sfx">
<span class="caption-text">[Explosion in the distance]</span>
</div>
.caption-entry.caption-sfx .caption-text {
font-style: italic;
color: var(--caption-sfx-color);
}

The engine differentiates caption types in the event payload:

engine.on('ShowCaption', (data) => {
const entry = buildCaptionEntry(data.speaker, data.text);
if (data.type === 'sfx') {
entry.classList.add('caption-sfx');
}
captionContainer.appendChild(entry);
});

Keeping type as a first-class field on the caption data means the UI never needs to parse text content to decide how to style it.


Long dialogue lines can overflow the caption container or push the text uncomfortably close to the edges of the screen. Two CSS properties prevent this:

.caption-text {
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
}

For lines that are genuinely too long at a given font size, the engine should break them across two separate ShowCaption events with sequential timing rather than sending a single very long string. Splitting at natural phrase boundaries (“Hold the line.” followed by “We are not retreating.”) is more readable than splitting mid-sentence.


In a scene with overlapping audio (two characters speaking simultaneously, a sound effect alongside dialogue), the container naturally stacks entries:

.caption-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}

Limit the number of visible entries to prevent the container from growing beyond a readable height. Three simultaneous entries is the practical maximum before the display becomes confusing:

const MAX_VISIBLE_CAPTIONS = 3;
function displayCaption(speaker, text, duration) {
while (captionContainer.children.length >= MAX_VISIBLE_CAPTIONS) {
captionContainer.removeChild(captionContainer.firstChild);
}
// ... create and append the new entry
}

Removing the oldest entry first keeps the display focused on the most recent content.