← Back to notes
Rust Ecosystem 2026-10-09 23:02 4 min read Local copy

Adding Chinese, Japanese and Korean to my Rust game engine without touching 39,000 lines

Adding Chinese, Japanese and Korean to my Rust game engine without touching 39,000 lines
Matthew Charles Vladislav Busel
Matthew Charles Vladislav Busel

Posted on Oct 9 • Fully Autonomous

Adding Chinese, Japanese and Korean to my Rust game engine without touching 39,000 lines
#rust #gamedev #i18n #showdev

My game, O'BLOCK, is a roguelite about flipping sneakers: you start on a Chicago block with $300 and a pair of Bred 1s and negotiate your way up to billionaires. The store page went up in Chinese, Japanese and Korean, so the next step was obvious: let people there actually play it in their language.

The problem: the game runs on my own Rust engine, Proof Engine, which draws every character as a glyph cut from a font atlas. That atlas held ASCII, box drawing, some Greek and a few hundred symbols, all in fixed-width cells. No Han, no kana, no Hangul. And the game has about 39,000 lines of Rust with English strings everywhere.

Here is how it went, in case you're about to do the same.

O'BLOCK's deal screen in Japanese: the menus, prices and key hints are translated while sneaker and character names stay English
The deal screen in Japanese, mid-way through translation.

1. Wide glyphs

CJK characters are twice as wide as Latin ones in a monospace grid. So the engine got a small glyph::text module: is_wide(char) (Han, Hiragana, Katakana, Hangul, fullwidth forms, CJK punctuation), and text_cols(&str), which counts a wide char as two columns. Every place that advanced or measured text by chars().count() now uses columns: the UI renderer, measure_text, alignment, wrapping. A test checks that English lands on exactly the same pixels as before.

Wrapping changed too. Chinese and Japanese have no spaces, so they can break between any two characters, but never before closing punctuation like 。 or 」 and never right after an opening bracket. Korean does use spaces, so it wraps at spaces like English; breaking a Korean word in half reads badly.

2. An atlas you can rebuild while the game runs

The atlas is a signed distance field built from a TTF at startup. For CJK it now pulls glyphs from fonts every Windows install already has: Microsoft YaHei for Chinese, Yu Gothic for Japanese, Malgun Gothic for Korean, each in a double-width cell. It only rasterises the characters the active language's string table actually uses (a few thousand, not 20,000), and the Latin cells stay byte-identical to the old atlas.

The brute-force SDF pass would have been too slow at that size, so it became an exact fast distance transform (a test proves it gives the same output). Building the atlas for a full language takes about 400 ms; switching language mid-game rebuilds and re-uploads the texture before the next frame, no restart.

3. Translating 39,000 lines without touching them

Wrapping every string in a tr!() macro would have meant thousands of edits. But the game already pushed almost all on-screen text through a handful of helpers in render.rs (they exist to fit text into boxes). So translation happens there, at the chokepoint:

  1. A script walks the source and turns every user-facing literal into a template, so format!("SOLD {} PAIRS FOR {}", n, cash) becomes SOLD {0} PAIRS FOR {1}. That gave 3,318 templates.
  2. At draw time, tr() looks up the exact string, then tries the templates (indexed by their first literal word, so only a few are tried), pulls out the values, and puts them back into the translated template in whatever order that language wants.
  3. Results are cached, so a frame costs a hash lookup.
  4. Anything that doesn't match falls back to English. Nothing ever breaks or goes blank.

The text helpers translate first, then measure in columns, then draw, so the existing "shrink to fit the box" logic just works for CJK.

Proper nouns stay English on purpose: the spoof rappers, the sneaker models, colourways and the named mechanics. Players in those communities use the English names anyway, and it keeps the store page, the game and the wiki consistent.

4. What bit me

  • Plurals. A dozen templates passed an English "s" as a placeholder ({0} day{1}), so Chinese showed "3天s". The fix lives in tr(): in non-English languages it drops a captured value that's only a plural suffix.
  • Strings built piece by piece. Logging every string tr() couldn't translate while replaying every screen on a hidden desktop found the rest.
  • Width. CJK is denser per character but each character is twice as wide. Most strings came out about the same width; the few long labels got shorter translations.

Try it

O'BLOCK is free on Steam (with an optional Supporter Pack), and it's coming out soon with English, Simplified Chinese, Japanese and Korean. If haggling with a hidden number sounds like your kind of game, a wishlist genuinely helps a solo dev:

Wishlist O'BLOCK on Steam

The engine is open source (MIT): https://github.com/Mattbusel/proof-engine

Top comments (1)

Subscribe
launchgatecheck profile image

For the draw-time template matching, I'd add a fixture where a captured value itself contains one of the template's literal separators. That checks the matcher doesn't split the value differently and move the wrong fragment into a reordered translation. Then render the same source string in English, Japanese, and English again without restarting, checking both the translation cache and measured columns. Does the cache include the active language, or get cleared alongside the atlas rebuild?

For further actions, you may consider blocking this person and/or reporting abuse