Introduction

I’m currently in the process of translating my latest game, so I decided to write about the system I’m using to make things simpler for myself. I’m doing this for two reasons:

  1. It may be helpful to someone else
  2. I came up with this system last year and I had forgotten all the details and had to figure them out by looking at my parser code. I’d rather not have to do this again next year!

The approach I’m using is heavily inspired by the one Ron Gilbert uses, as described in the Thimbleweed Park blog.

The scripting language for my games is currently Lua, but that may change as I progress with my game engine if I decide to take the crazy step of creating my own scripting language.

Step 1: writing text while the game is being developed

During the development of the game the way I write text can be observed in the following snippet, coming from the code inside a dialogue script.

1
2
3
4
5
say(lee, LEE("I must have sat on the temporal navigator."))
say(lee, LEE("That's why I couldn't find it anywhere."))
say(ines, INES("TEMPORAL NAVIGATOR!?"))
say(lee, LEE("Nothing to worry about."))
say(lee, LEE("I carry around this reality-fixing device™ just for these occasions."))

Here say is the function that tells the game to write a sentence on the screen and animate a character speaking, ines is the character object (a Lua table), and INES is a function that for the time being just takes a string as input and then returns it. Animation of Ines saying “TEMPORAL NAVIGATOR!?”

Other than having to remember to wrap the text in the INES and LEE functions, at this stage nothing special needs to be done, and we are able to write the text directly in the source files, which makes developing much easier.

Step 2: after the game is finished

We finally finished writing the dialogue for the game! Well done!

At this point we are ready to decouple the text from the source files, which is essential if we want to translate the game in a relatively painless way.

This is done through a Lua script that parses all the source files, finds all the functions such as INES and LEE, and modifies their inputs to add a line id. Additionally, the script creates a new Lua file which returns a table containing all the text. After running this script, the snippet becomes

1
2
3
4
5
say(lee, LEE(1, "I must have sat on the temporal navigator."))
say(lee, LEE(2, "That's why I couldn't find it anywhere."))
say(ines, INES(3, "TEMPORAL NAVIGATOR!?"))
say(lee, LEE(4, "Nothing to worry about."))
say(lee, LEE(5, "I carry around this reality-fixing device™ just for these occasions."))

and the file with the output table is

1
2
3
4
5
6
7
8
9
local t = {}

t[1] = "I must have sat on the temporal navigator."  -- {LEE, dialogues/lee.moon}
t[2] = "That's why I couldn't find it anywhere."  -- {LEE, dialogues/lee.moon}
t[3] = "TEMPORAL NAVIGATOR!?"  -- {INES, dialogues/lee.moon}
t[4] = "Nothing to worry about."  -- {LEE, dialogues/lee.moon}
t[5] = "I carry around this reality-fixing device™ just for these occasions."  -- {LEE, dialogues/lee.moon}

return t

Note that the output file has comments on each line telling us which character is speaking and which source file the line came from, to help with the translations.

At this point we can’t pretend the INES and LEE function are as trivial as they used to be. The actual functions we use is

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function tr(a, b)
    if type(a) == "string" then
        return a
    elseif type(a) == "number" then
        return tr_text[a]
    end
end

INES = tr
LEE = tr

and INES and LEE are simply convenience names for the tr function, whose purpose is to let us know which character is talking through the comments in the output file from above. If the tr function is only passed a string (like we did during development) then it just returns the string itself; if, however, we have a line id, the string is ignored and tr returns the element with key id from the tr_text table, which is of the same form as the one obtained in the script output file.

The way translations are implemented is by making several copies of the output table (one for each language) and import them in the game. Changing the language is then as easy as setting the tr_text table to the correct one for the language:

1
2
3
4
5
6
text_en = require("translations.en")
text_it = require("translations.it")
text_fr = require("translations.fr")

-- set language to french
tr_text = text_fr

Adding a line after the fact

What if we need to do some changes after the script was already ran? Easy! The script actually does this:

  1. First we do a pass of all the files and check if any lines already have an id, then we record the highest id (or set it to 0 if there are none) in a variable high_id.
  2. Now we do a second pass. If a line already has an id we leave it as is. If a line does not have an id, we assign it high_id + 1 and then increment high_id by one.

So for example if we change the snippet to

1
2
3
4
5
6
say(lee, LEE(1, "I must have sat on the temporal navigator."))
say(lee, LEE(2, "That's why I couldn't find it anywhere."))
say(ines, INES(3, "TEMPORAL NAVIGATOR!?"))
say(lee, LEE(4, "Nothing to worry about."))
say(ines, INES("I forgot to say something!")
say(lee, LEE(5, "I carry around this reality-fixing device™ just for these occasions."))

we obtain the following when we run the script:

1
2
3
4
5
6
say(lee, LEE(1, "I must have sat on the temporal navigator."))
say(lee, LEE(2, "That's why I couldn't find it anywhere."))
say(ines, INES(3, "TEMPORAL NAVIGATOR!?"))
say(lee, LEE(4, "Nothing to worry about."))
say(ines, INES(6, "I forgot to say something!")
say(lee, LEE(5, "I carry around this reality-fixing device™ just for these occasions."))
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
local t = {}

t[1] = "I must have sat on the temporal navigator."  -- {LEE, dialogues/lee.moon}
t[2] = "That's why I couldn't find it anywhere."  -- {LEE, dialogues/lee.moon}
t[3] = "TEMPORAL NAVIGATOR!?"  -- {INES, dialogues/lee.moon}
t[4] = "Nothing to worry about."  -- {LEE, dialogues/lee.moon}
t[6] = "I forgot to say something!"  -- {INES, dialogues/lee.moon}
t[5] = "I carry around this reality-fixing device™ just for these occasions."  -- {LEE, dialogues/lee.moon}

return t

Note than lines are added to the file with the table in the order in which they are parsed rather than sorted by id—this is to make translations easier, since otherwise adding a sentence would bump it to the end of the file.

One final note: when we run the script and the output file already exists, lines that are not in the table are added, but the ones that are already are not overwritten. This is so that if we add a line after the translation has already been done, translated lines are not reverted to the original language!

Conclusion

That’s it! This is the way I deal with translations. Hopefully this post will be helpful to someone, even if that someone is just myself next year.