QA & troubleshootingこの記事を日本語で読む

An emoji renders as two broken halves, or silently disappears

A player name or chat message contains one emoji, and something goes wrong: the emoji renders as two disconnected fragments, it vanishes entirely leaving a gap, or the string gets rejected by a character limit that should have had plenty of room left. None of these look like a font problem — the emoji works fine everywhere else in the game. The common cause is that some code along the way is counting or cutting the string by the wrong unit.

This requires being precise about a few Unicode terms that get used loosely: code point, UTF-16 code unit, and grapheme cluster are three different things, and the bug always turns out to be a mismatch between which one the code assumed it was working with.

Code points, UTF-16 code units, and why they differ

Every character in Unicode is a code point — a number identifying it. Most common Latin, kana, and CJK characters have a code point that fits in a single 16-bit unit. Many emoji and some rarer CJK characters live outside that range and need two 16-bit units to represent one code point — a mechanism called a surrogate pair.

JavaScript's string length and index-based operations (.length, charAt, slicing by index) count UTF-16 code units, not code points and not visible characters. So a single emoji that requires a surrogate pair reports as length 2, and slicing the string at index 1 lands you in the middle of that pair — not at a character boundary at all.

const s = "🎮";
s.length            // 2 — one surrogate pair, not one character
s.slice(0, 1)       // "\ud83c" — half of the pair, renders as a broken glyph
[...s].length        // 1 — iterating by code point gets this right

Grapheme clusters: one more layer above code points

Even iterating by code point is not always enough. Many emoji that look like a single character are actually a sequence of multiple code points joined by a zero-width joiner (ZWJ), or a base emoji followed by a variation selector or skin-tone modifier code point. A family emoji, for example, can be several person emoji joined by ZWJ into one visual glyph. What a person perceives as one character on screen is called a grapheme cluster, and it can span multiple code points, each of which can itself span multiple UTF-16 units.

Cutting a string between two code points that belong to the same grapheme cluster does not crash, but it breaks the sequence: instead of one combined emoji you get two or three separate ones rendered side by side, or a lone variation selector with nothing to attach to.

  • Code point: one Unicode character number
  • UTF-16 code unit: the 16-bit storage unit JavaScript strings are actually indexed and measured in — one code point is one or two of these
  • Grapheme cluster: what a reader perceives as a single character — can be built from several code points joined by ZWJ or modifiers

Why naive substring and truncation break these

Any code that truncates a string to N characters by slicing at an index, or that measures a character limit with .length, is implicitly assuming one code unit equals one character. That assumption holds for plain ASCII and most CJK text, which is exactly why the bug so often ships unnoticed until someone tests with emoji, and then reappears as a support ticket instead of a QA finding.

// Truncating a name limit naively
function truncate(str, max) {
  return str.slice(0, max); // cuts mid-surrogate-pair, mid-ZWJ-sequence
}
truncate("Player🎮Name", 7)
// may cut inside the emoji's surrogate pair, producing a broken glyph

How to count and cut text safely

The fix is to stop indexing by UTF-16 code unit and use an operation that understands grapheme clusters instead. JavaScript's Intl.Segmenter with granularity set to grapheme iterates a string one visible character at a time, correctly grouping surrogate pairs, ZWJ sequences, and modifiers together — so counting and truncating both become correct automatically.

  • Never slice or truncate by raw string index when the text may contain emoji or other supplementary-plane characters
  • Prefer Intl.Segmenter (grapheme granularity) for both counting and cutting user-facing text
  • If a character-count limit exists for gameplay or UI reasons, apply it in grapheme clusters, not code units
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const graphemes = [...segmenter.segment("Player🎮‍♂️Name")].map((s) => s.segment);
graphemes.length          // correct visible character count
graphemes.slice(0, 7).join("") // safe truncation — no split emoji

Prevention

Add emoji, including a ZWJ sequence and a skin-tone-modified emoji, to your test data for any field with a length limit — player names, chat, custom titles. If the string round-trips through truncation, storage, and re-render without splitting, the field is safe; if it splits with these specific inputs, it will split for players too, and it will look far more broken in-game than in a unit test, because a split surrogate typically renders as a visible replacement glyph rather than failing silently.

Related articles