Engineeringこの記事を日本語で読む

Dialogue system design: exporting branching text for translation

A custom dialogue system is often the first piece of a game that feels genuinely yours: nodes, branches, conditions, a small editor window you built yourself. It works, the writing flows, the game ships in one language. Then someone asks for the text so they can translate it, and the request turns out to be much harder to answer than expected. The lines live inside graph nodes, tangled up with conditions and speaker metadata, and the only way to read them in the order a player would hear them is to play the game.

The problem is almost never the translation itself. It is the shape of the data. Dialogue is the hardest text in a game to export cleanly because it carries three things that ordinary UI strings do not: branching structure, speaker identity, and values that are only known at runtime. If the export drops any of those, the translator is working blind, and you find out on screen months later.

This article is about designing the export, and the matching import, so that all three survive the round trip.

What a translator needs is not what your runtime needs

Your runtime wants a graph. It needs fast lookup by node id, conditions it can evaluate, and pointers to whatever comes next. None of that helps a person who has to write natural dialogue in another language. A translator needs a linear, readable document: who is speaking, what was said immediately before, what the player did to arrive here, and roughly how much room the line has on screen.

Both views are legitimate, and the mistake is expecting one file to serve both without a conversion step. Treat the export as a projection of your authoring data, not as a copy of it. Your editor format stays whatever suits your editor. The export is a flat table generated from it, and the import writes translations back by key. Once that separation exists, every remaining decision is simply a question of what to include in the projection.

A useful test for the projection: can someone who has never played your game answer all of these questions from the exported file alone?

  • Who is speaking this line, and who are they speaking to
  • What scene or chapter this belongs to, and what happens around it
  • Whether this is a spoken line, a player choice, or an incidental bark
  • What was said immediately before, and what the player did to reach it
  • What each placeholder will be replaced with, with a realistic sample value
  • How much space the line has, if the box it appears in is fixed

One displayed line, one key, and everything else in columns

The unit of translation is what the player reads on screen at one moment. It is not a node, and it is not a conversation. A node that holds five consecutive lines and gets exported as one blob makes the translator responsible for preserving your internal line breaks and the exact number of segments. One merged or dropped segment silently desynchronises the rest of the conversation, and nothing in the file will tell you it happened.

Give every displayed line a stable id that does not depend on its position in the graph. Keys that encode position, like conversation03_node07_line2, break the moment you reorder a scene: every downstream key changes, previous translations stop matching, and reviewers lose their history. A short id assigned when the line is authored and never reused is far safer, with the readable information carried in separate columns rather than in the key itself.

This also means splitting or merging lines after translation is a data change, not a text edit. If you decide one long line should become two on screen, that is a new key and a new line to translate, in every language. Deciding that early, while the writing is still moving, costs much less than discovering it during a translation pass.

The single most common thing that ruins a dialogue export is baking the speaker name into the text, so that the translatable string reads as the name, a colon, and then the line. The name is now inside a translatable string, repeated hundreds of times. A translator can change it accidentally on line 400 and consistently everywhere else, so the inconsistency looks deliberate. You also lose the ability to style or colour the name separately, and any later rename becomes a search-and-replace across translated text you cannot read.

Keep the speaker as an id in its own column and resolve it at display time through your normal string system. Character names are absolutely localizable — transliteration choices, honorifics and consistency all matter — but each name should be exactly one key, translated once, not re-typed on every line.

Scene, chapter, portrait or emotion tag, and a short free-text note are the columns that make an out-of-context line translatable. They cost almost nothing to emit and they pre-empt most of the questions a translator would otherwise have to send you and wait on. A note field that says the line is sarcastic, or that the speaker does not yet know the listener is lying, changes the translation more than any style guide will.

key,type,conversation,order,speaker_id,text,note,max_chars,placeholders,voice_clip

Making branches readable in a flat file

Branching is the context a flat file destroys by default. If a translator sees ten lines in file order with no indication that four of them are mutually exclusive alternatives, they will eventually write a reply to a question the player never asked, and it will read as a bug.

Three cheap fields fix most of this. A conversation id groups an entire tree so it can be read together. An order or path column sorts the lines into a sequence that reads sensibly top to bottom. A short reached-by note names the choice or condition that leads to this line, in plain language.

Player choices deserve particular care. The choice text and the line that answers it must be visible next to each other, because the answer often has to follow the choice grammatically — and in some languages that dependency is much stronger than in English. Mark rows with a type column that distinguishes narration, spoken line, player choice, and ambient bark, then sort so a choice sits immediately above the branch it opens.

Do not export conditions as logic. A cell reading that a flag must be at least three is noise to a translator. The same information written as a sentence — that this line is said when the player has already refused twice — is context they can act on. Generate that sentence at export time if your editor knows enough to do it, and hand-write it where it does not.

Variables, names, and sentences you must not concatenate

Dialogue is where runtime values show up most: the player's name, an item just picked up, a number of remaining attempts. Export the exact list of placeholder tokens for each row in its own column, so an automated check can verify that the translation still contains the same set. A translation that quietly drops a token produces a sentence missing a word; one that adds a token that does not exist produces a literal placeholder on screen.

The deeper design issue is building sentences by concatenation. Joining a fixed prefix, a variable and a fixed suffix works comfortably in English and breaks in a large share of other languages, because word order, particles, articles, grammatical gender and plural forms all depend on the inserted value. Keep the whole sentence in one string with placeholders inside it, and let the translator move the placeholder to wherever the sentence needs it. If the sentence has to change shape by count or by gender, that variation belongs to the string through a plural or select mechanism, not to an if-statement in your dialogue runner.

Player-entered names are a special case worth flagging in the note column. They cannot be inflected, they may contain characters your font does not cover, and they can be any length. Where a language would normally decline the name, the translator needs to know they must write around it — and they can only know that if you tell them which placeholder holds free text the player typed.

// breaks in translation
Show(Localize(found_prefix) + itemName + Localize(found_suffix));

// survives translation
Show(Localize(item_found, { item: itemName }));
// item_found (en): You found {item}!
// item_found (ja): {item} を手に入れた!

Getting it back in, and proving it is right

Import by key, never by row order. Spreadsheets get sorted, filtered, split between two people and re-joined; rows get deleted and re-added. If your importer assumes row three is still row three, one sort by speaker name will shift an entire script by one line and the result will look almost plausible. Require the key column, ignore position entirely, and refuse the file if keys are missing or duplicated.

Then run the mechanical checks before anything reaches a build. These are the ones that catch real damage and cost nothing to automate:

  • Every key from the source export is present in the returned file, and no unknown keys were introduced
  • The set of placeholder tokens per row matches the source exactly
  • Speaker, conversation and order columns are unchanged from what you exported
  • No line exceeds the max length declared for the box it appears in
  • Rich-text or markup tags are balanced and unchanged in count
  • No cell is empty, and no cell is still identical to the source text where that would be suspicious

The last check is in the game, not in the file

Automated checks confirm the data is intact. They cannot tell you that a line fits the box but reads as a different character, or that a joke landed as an insult. For that you need to see the text in place, and replaying an entire branching game in every language is not a plan.

Build a dialogue playback mode instead: a debug screen that walks every conversation and every branch in order, showing each line with its speaker in the currently selected language, ignoring the conditions that would normally gate it. It takes an afternoon, it is the only practical way to see branches that are hard to reach in normal play, and it turns review from a playthrough into a reading pass. Pair it with a dump of every line to a text file per language, so a reviewer who does not have a build can still read the whole script in order.

The goal of all of this is one property: adding a language should be a data operation, not an archaeological dig through your node graph. If you can export, translate, import and verify without opening the dialogue editor, the system is designed correctly.

Related articles