Running localization off a Google Sheet: four small problems, none of them code
- Unity
- Localization
- WebGL
- Architecture

SN8KE ships in four languages — English, Ukrainian, Spanish, and Chinese, with the source of truth sitting in a Google Sheet, not a Unity Localization Tables asset and not a translation vendor. One spreadsheet, one column per language, one key column. The reasoning was simple: whoever's writing copy for the game shouldn't need Unity open to fix a typo, and a published CSV endpoint gets you that for free with zero infrastructure.
Switching languages live in the settings popup — every LocalizedString on screen refreshes from the same Google Sheet.
The service that reads it is small on purpose. What's worth writing up is the four ways a "just read a CSV" system finds to surprise you — none of which were bugs in the service itself.
The shape of it
ILocalizationService does three things: load a table on startup, resolve key → string for the current language, and fire an event when the language changes. The table is lang → key → value, built by parsing the published CSV once at boot.
public async UniTask InitializeAsync()
{
_sources = CreateLocalizationSources();
_table = await LoadFirstAvailableAsync();
_isReady = true;
SetLanguage(string.IsNullOrEmpty(_settingsService.Language)
? FallbackLanguageKey
: _settingsService.Language);
}
The one deliberately awkward piece: components sitting on scene prefabs — a LocalizedString behavior that reads a key and writes to a TMP_Text — can't get ILocalizationService through the DI container, because they're not resolved through it. VContainer injects into objects the container knows about; a component sitting on a prefab dropped into a scene by hand isn't one of them. The fix is a static bridge:
public static class LocalizationHost
{
public static ILocalizationService Service { get; private set; }
public static event Action ProviderChanged;
public static void SetProvider(ILocalizationService service)
{
Service = service;
ProviderChanged?.Invoke();
}
}
Bootstrap calls SetProvider once, after the service finishes loading. Every LocalizedString in the scene subscribes to ProviderChanged and refreshes. It's a deliberate escape hatch from the DI graph, not an accident — the alternative was wiring every single label manually, which doesn't scale past the third screen.
Problem 1: edits to the sheet took minutes to show up
First report, early on: fixed a typo in the sheet, rebuilt, redeployed — same typo on screen. Rebuilding again didn't help either. This pointed at caching, and there are two independent layers of it between a spreadsheet edit and a browser tab: Google's own publish-to-web endpoint doesn't republish on a fixed schedule, and the browser (plus whatever CDN sits in front of the deployed site) caches a GET response by URL regardless of what the server says about it.
Neither layer respects a plain fetch to the same URL every time. The fix that actually works is making every request look like a different URL:
char separator = _url.Contains("?") ? '&' : '?';
string bustedUrl = $"{_url}{separator}_cb={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
using var request = UnityWebRequest.Get(bustedUrl);
request.SetRequestHeader("Cache-Control", "no-cache, no-store");
A timestamp query param is a blunt instrument, but it's the one thing that reliably works against caches you don't control — Google's snapshot cache doesn't know or care about a _cb param, so it treats the request as new every time.
Problem 2: a formula that's correct in the sheet and wrong in the export
Not every language has a human translator attached to it yet. For the ones that don't, the sheet leans on =GOOGLETRANSLATE(en_cell, "en", "uk") right in the target-language column — type the English line once, every other language column fills itself in as a first-pass draft, and a real translator swaps in an actual sentence whenever one's available. It's a placeholder pipeline, not a permanent one, but it means new strings are never launched with visibly blank cells for languages nobody's gotten to yet.
Opening the sheet in a browser, those formula cells showed real translated text — the draft pipeline working as intended. The CSV export, the thing the game actually reads, showed a loading placeholder or nothing at all for the same cells.
This one isn't a caching problem, and no amount of cache-busting fixes it. GOOGLETRANSLATE() is a volatile external function — it depends on a live call to Google's translation API, evaluated by whatever's currently rendering the sheet. The interactive spreadsheet, with an active editing session, recalculates it. The publish-to-web CSV snapshot is generated in the background, and there's no guarantee that background process forces every volatile formula to fully resolve before the snapshot gets written — sometimes it captures whatever the cell's last known value was, which can be the loading state.
The fix lives entirely in the spreadsheet, not the code: select the formula cells, copy, paste as values. That freezes the result of the translation into the cell as plain text, and plain text always survives export correctly. It reads as an odd thing to spend debugging time on — the bug wasn't in anything we wrote, and the fix isn't a code change at all, which is exactly why it's worth naming: the failure mode was a spreadsheet formula lying to a subsystem miles away from where the formula lives.
Problem 3: a font that can't read what the table now says
TextMeshPro doesn't render arbitrary Unicode out of the box — a Font Asset is a fixed SDF atlas baked from a specific character set, chosen when you generate it. English, Ukrainian, and Spanish fit comfortably in a broad Latin+Cyrillic preset. Chinese doesn't get that convenience: including every possible CJK glyph produces an atlas too large to be practical, so the standard move is a custom character list — exactly the glyphs the actual translations use, and nothing else.
That means the font asset needs to be regenerated every time the sheet grows, and doing it by hand means opening the CSV, eyeballing the Chinese column, and copying out unique characters — tedious and easy to undercount on a sheet with 80+ rows. Scripting the extraction is more reliable, but it surfaced its own bug: a naive line-split parser undercounts, because CSV allows quoted fields containing literal newlines (the sheet had a multi-line welcome message), and splitting on \n before respecting quotes chops that field into several garbage rows.
// naive — breaks on any quoted field containing a real newline
const lines = csv.split(/\r?\n/);
// correct — a real CSV state machine, quote-aware
function parseCsv(text) {
let inQuotes = false;
// ...walks the string char by char, only splitting on
// commas/newlines when not inside a quoted field
}
Running the character extraction with a real parser instead of split('\n') found the login welcome message's characters that the naive version had silently dropped — the count went from 73 unique glyphs to 164 once the sheet grew and the parser stopped losing rows to multi-line fields. Small thing, but it's the difference between "some strings render as tofu boxes on Android" and not.
Problem 4: a settings popup that can't see the localization service
SettingsPopup lives in the lowest-level UI assembly (SN8KE.UI) — deliberately, so popups don't depend on gameplay or meta code. ILocalizationService lives in SN8KE.App.Localization, which already references SN8KE.UI for the UI primitives it needs. Wiring the popup's language dropdown straight to ILocalizationService would have made SN8KE.UI depend on SN8KE.App.Localization, which already depends on SN8KE.UI — a cycle, which Unity's assembly system refuses to compile.
The fix is the same shape as the DI-vs-prefab problem earlier, applied to assemblies instead of dependency injection: a small interface, ILanguageProvider, living in the low assembly (SN8KE.UI) that already can't create a cycle by definition. LocalizationService implements it as a second interface alongside ILocalizationService, registered once, resolved as either depending on who's asking:
public sealed class LocalizationService : ILocalizationService, ILanguageProvider
SettingsPopup depends on the narrow ILanguageProvider — four members, not the full service surface — and never needs to know the real implementation lives one layer up. Same pattern showed up again later for a match-to-menu communication path (IQuestsProgressSink, in the previous architecture post) — once you've hit this shape once, it's fast to recognize: whenever a lower layer needs to react to a higher one instead of the usual direction, the interface belongs at the lowest common ancestor, not wherever feels natural to declare it.
What ties these together
None of the four problems lived in the fifteen or so lines that actually resolve a key to a string — that part worked on the first try and never changed. Every real issue showed up at a boundary: the boundary between a spreadsheet and its own export snapshot, between a browser and a cache it won't invalidate on request, between a translated string and a font that's never seen its characters before, between two assemblies that each had a legitimate reason to reference the other. A system this small doesn't accumulate bugs in its logic — it accumulates them in the seams around whatever it's talking to.