s&box localization in a nutshell
I recently added a localization to Terry Dev Tycoon, and out of everything in Update 1, it ended up being the most time-consuming part. That caught me off guard, so before I write a full deep-dive on the game, I wanted to jot down a quick note on how localization actually works in a s&box game.

Translating the game
The s&box documentation has a dedicated page on this. And at first glance it really does cover the essentials:
- Resources live in
YourProject/Localization/LangCode/file.json. - The format is
"key": "value". - To use a localized string in UI, just prepend
#to the key — e.g.<p>#your.resources.key</p>.
That said, there are a few gotchas. Let's go through them.
Dynamic strings
Odds are the simple case above won't cover everything. In my game there's a project creation screen where each game focus shows its current percentage.

The code without localization looks something like this:
<span class="h3">Engine: @CalculatePart(Prototype.Engine)%</span>
You'd think swapping Engine for #ui.aspect.engine would do it — but no. Right now the localization engine looks for an exact match, meaning it'll treat everything after the key as part of it too: : 23%, going by the screenshot example.
This is where the static Language class comes in. Its GetPhrase method lets you fetch a string by key directly, bypassing the engine used for .razor files. Calling Language.GetPhrase("ui.aspect.engine") returns Engine in English. Note — no # needed here.
So the line ends up looking like this:
<span class="h3">@Language.GetPhrase("ui.aspect.engine"): @CalculatePart(Prototype.Engine)%</span>
That was the trickier case where the string is built dynamically. But there are simpler ones too — like when a string is followed by punctuation such as :, or when you just need to concatenate a few strings together. This approach lets you keep that kind of stuff out of the localization files and focus them purely on text.
The cost of late localization
So far this is the most workable approach I've found for dynamic strings. In places it's not exactly elegant, since I'd already tied a lot of things to some strings before getting to localization. For instance, that same example actually looks like this in my codebase:
<span class="h3 @focusClasses[eng]">@Language.GetPhrase(eng.Substring(1)): @CalculatePart(Prototype.Engine)%</span>
That happened because the same constants were being used both for styles and legend labels in the pie chart component, and for the percentage labels on the project creation page. So I ended up having to strip the leading # right there in the markup.
Asset localization
Beyond text baked into the UI, you might also have text that's loaded from assets — or you might need to vary the loading logic itself based on the current language. In Terry Dev Tycoon, several things are loaded and localized separately:
- game reviews;
- task descriptions in the tracker;
- in-game event text.
These all live in TOML files. That's not a problem in itself, since you can still use the same keys that s&box will pick up. But game events needed a bit of a workaround.
In my previous post about building this site, I talked about why I chose TOML for the game — and one of the main reasons was multiline text support. Sacrificing that to fit into a JSON localization file wasn't something I wanted to do, so the only real option was to have separate files per locale and load them dynamically.
Language comes to the rescue again — this time through its Current property. I added a language check to my event loading code:
var lang = Language.Current.Abbreviation;
if (lang == "ru" && _byLanguage.TryGetValue("ru", out var ruList))
return ruList.Find(x => x.Id == id);
else
return _byLanguage["en"].Find(x => x.Id == id);
Special handling only exists for supported languages. If a language isn't supported, or something hasn't been fully translated yet, it falls back to English.
One trap worth pointing out here: this would not have been as straightforward if I needed an event log — somewhere players could go back and re-read past events. In that case I'd be storing already-localized strings in the save file (or just in memory), and those won't magically retranslate themselves when you change the language setting — not without rolling your own solution for it. Which is exactly why it's better to lean on the built-in system as much as possible.
How to make future localization easier on yourself
I knew from the start I'd eventually add localization. But it was low priority, development kept dragging on, and I eventually cut it from the pre-release TODO list entirely, leaving it for later. Still, I did manage to reduce the future workload a little: from the beginning I was putting all text into constants. For example:
<div class="entry active" onclick="@OpenGameCreation">
<i class="head">sports_esports</i>
<div class="body">@create</div>
</div>
@code {
private const string create = "Make a new game";
}
This made the localization pass noticeably smoother — instead of scanning markup by eye to find hardcoded strings, everything was in one place. Swapping them out was as simple as:
@code {
private const string create = "#ui.top.create";
}
And that was it. Sure, it's nicer to write markup when you can see the actual text — but since you're going to end up with unreadable keys anyway, you might as well move to named constants from the start. It didn't work out perfectly — I mentioned the dynamic string issue above, and a handful of strings slipped through the cracks, so after updating the constants I still had to do several full UI checks in-game. But overall the approach felt right.
Wrapping up
When you get down to it, there are really just three things you need to know for localization:
- localization in
.razorvia#; - dynamic lookups via
Language.GetPhrase("key"); - arbitrary locale-based logic via
Language.Current.
But for each of those I had to dig through either Discord or the API Reference to figure it out — so hopefully this saves someone else some time.