QWERTY is not universal

Handling AZERTY and other keyboard layouts in Roblox.

Hmm. What even is the point of having different keyboard layout compatibilities for a Roblox game? Well, this is a trivial question, but once you look at how UserInputService handles keystrokes, it's actually a lot stranger than you might think.

WASD is the universal standard for movement in PC gaming. If you map W on an AZERTY keyboard, you're mapping the key that outputs Z. So, how come pressing Z moves your character as though you pressed W?

Well, UserInputService isn't aware of keyboard layouts. KeyCode is a position, not a character. Those are two different things and Roblox exposes one for logic and the other for display. So, Roblox normalizes physical keys to their US QWERTY equivalents.

WHY?

Solution to different keyboard layouts

GetStringForKeyCode. This returns the character for a given keycode, or "" if no such character exists on the local player's keyboard.

This isn't similar to typing in a TextBox though. Typing into a TextBox is unaffected by any of this, since text entry goes through the OS layout and IME rather than the raw keycode path.

So, for my Last Word game, I had the option to either have a TextBox implementation or GetStringForKeyCode. I ended up using GetStringForKeyCode.

Movement is the famous version of this problem, but it's also the forgiving one. An AZERTY player with a bad WASD bind is annoyed. Last Word is a word game, where the entire loop is typing letters against a clock, so a player whose keystrokes resolve to the wrong characters simply cannot play. That's what makes the rest of this post a typing problem rather than a movement one.

Why not a TextBox

The reason I did not use a TextBox implementation (even though it would've been more user friendly), is because of how Roblox handles TextBox capturing/prioritizing. I was not able to find a convenient way to handle both TextBox capturing and chatting at the same time. This felt very inconvenient for people trying to communicate. I would've had to make a whole edge case for losing focus, which I did not want to do. I also would've had to detect chatting requests. I also would've had to detect whenever the user was focused on the chat TextBox. And so on... The issues just never ended and kept coming. It felt like too much work for something that is unstable. UserInputService could just handle it for me and give me peace.

Though I may come back to using a TextBox implementation

The resolver

Everything goes through the ResolveKeyChar function. It takes a KeyCode and returns the character that key actually prints on this player's keyboard, or nil if the key isn't a character.

local function getPrintedLabel(keyCode: Enum.KeyCode): string
	local label = UserInputService:GetStringForKeyCode(keyCode)
	if label ~= '' then return label end

	if keyCode.Value >= 32 and keyCode.Value < 127 then
		return string.char(keyCode.Value)
	end

	return ''
end

local function layoutChar(keyCode: Enum.KeyCode, layout: KeyboardLayouts.Layout): string?
	if layout.Shifted then
		local shifted = layout.Shifted[keyCode.Name]
		if shifted then return shifted end
	end

	return layout.Keys[keyCode.Name]
end

return function(keyCode: Enum.KeyCode, keep: { [string]: boolean }?, layout: KeyboardLayouts.Layout?): string?
	if layout then
		local mapped = layoutChar(keyCode, layout)
		if mapped then return mapped end
	end

	local label = getPrintedLabel(keyCode)
	if utf8.len(label) ~= 1 then return nil end

	return foldAccents(lowerWord(label), keep)
end

Five things are happening, in a deliberate order:

  1. Check the game's own layout table first, if the current round has one. This is the non-Latin path and it's described in its own section below. Everything under it is the Latin path.
  2. Ask the OS layout. GetStringForKeyCode is the only source that knows the player's layout.
  3. Fall back to the enum's ASCII value. Enum.KeyCode values in 32..126 are their ASCII codepoints, so string.char recovers a sane US-QWERTY guess when the API returns "". That happens more than you'd like, on keys and platforms where nothing printable is reported.
  4. Reject anything that isn't exactly one character. utf8.len(label) ~= 1 throws out "", throws out multi-char names like "Backspace" that would otherwise get typed in as literal text, and does it in codepoints rather than bytes so é counts as one and not two.
  5. Normalize. lowerWord then foldAccents, described below.

Note the ordering of those last two: case is folded before accents. İ → i in LowerWord (Turkish dotted capital I doesn't survive string.lower), and Œ → œ → oe needs the lowercase form to exist before the fold table can expand it.

Normalizing, without destroying the languages that need accents

Last Word runs a per-language word list, and each language declares its own alphabet:

LANGUAGES = {
	English = { Chars = 'abcdefghijklmnopqrstuvwxyz0123456789\'-', Flag = '🇺🇸' },
	French = { Chars = 'abcdefghijklmnopqrstuvwxyzàâæçéèêëîïôœùûüÿ0123456789\'-', Flag = '🇫🇷' },
	Turkish = { Chars = 'abcçdefgğhıijklmnoöprsştuüvyz0123456789', Flag = '🇹🇷' },
	Hebrew = { Chars = 'אבגדהוזחטיכךלמםנןסעפףצץקרשת0123456789', Flag = '🇮🇱', RTL = true },
	Russian = { Chars = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя0123456789-', Flag = '🇷🇺' },
	Georgian = { Chars = 'აბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰ0123456789-', Flag = '🇬🇪' },
	-- Spanish, Portuguese, Italian, German, Swedish, Polish...
}

Twelve languages now, three of which aren't Latin at all. RTL on Hebrew flows down into the prompt renderer so the letters build right to left instead of left to right.

That Chars string gets exploded into a frozen { [string]: boolean } set once, and handed to the resolver as its keep argument. That's what makes the accent fold conditional instead of global:

if keep and keep[ch] then
	out[count] = ch               -- this language spells with it, leave it alone
else
	out[count] = FOLD[ch] or ch   -- otherwise strip it down to ASCII
end

So é survives in French and collapses to e in English. Without the keep set, a single global fold would make half the French dictionary unspellable; without a fold at all, an English player on a French layout couldn't type anything. Same table also handles the expanding cases (œ → oe, æ → ae, ß → ss) and eats the combining diacritics (U+0300–U+0327) so dead-key composition doesn't leave a stray mark in the buffer.

Enumerating binds instead of hardcoding A–Z

The Wordle mode in my game uses ContextActionService, which needs an explicit list of KeyCodes up front. Hardcoding Enum.KeyCode.A through Z would bind the wrong physical keys on non-QWERTY layouts. So the list is derived from the player's layout at bind time:

local function collectLetterKeys(): { Enum.KeyCode }
	local keys = { Enum.KeyCode.Backspace, Enum.KeyCode.Return, Enum.KeyCode.KeypadEnter }

	for _, keyCode in Enum.KeyCode:GetEnumItems() do
		if keyCode.Value < Enum.KeyCode.ButtonX.Value then
			local ch = resolveKeyChar(keyCode)
			if ch and string.match(ch, '^%a
#39;) then table.insert(keys, keyCode) end end end return keys end

Digits are their own mess

Every language's Chars now includes 0123456789, and digits can't ride the letter path. On a French AZERTY the number row is shifted — the unshifted key prints &, é, " and you need Shift for the digit. On US QWERTY it's the reverse. So the digit row gets its own ContextActionService bind at High priority, and one function reconciles the two cases:

local function typedDigitChar(keyCode: Enum.KeyCode): string?
	local digit = digitForKey(keyCode)
	if keyCode.Value >= Enum.KeyCode.KeypadZero.Value then return digit end

	local shifted = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift)
		or UserInputService:IsKeyDown(Enum.KeyCode.RightShift)
	local printed = resolveKeyChar(keyCode, TypingController.ValidChars, TypingController.KeyLayout)

	if printed == digit then
		return if shifted then nil else digit
	end

	return if shifted then digit else printed
end

Read it as: keypad is unambiguous, take it. Otherwise, if the key already prints its digit unshifted (QWERTY), Shift means the player wanted the symbol, so emit nothing. If it doesn't (AZERTY), Shift is how you get the digit, and unshifted gives you whatever the layout actually prints.

When asking the OS is the wrong question

Everything above assumes the player's OS layout can produce the characters the round needs. For Latin languages that holds. For Russian, Hebrew and Georgian it collapses immediately.

Picture the actual player: someone in a Russian round, sitting at a plain US QWERTY keyboard, with no Russian layout installed on their machine. GetStringForKeyCode faithfully reports q, w, e. None of those are in Chars for Russian, so every one gets dropped, and the player types nothing at all. Asking the OS was the wrong question. They don't have the layout, and telling a player to go install a system keyboard to play a Roblox game is not a real answer.

So for PC, the game ships its own layouts. ReplicatedStorage.Data.KeyboardLayouts is a frozen table keyed by language, mapping Enum.KeyCode.Name to the character that position prints under that language's standard layout:

export type Layout = {
	Keys: { [string]: string },
	Shifted: { [string]: string }?
}

return table.freeze({
	Russian = table.freeze({
		Keys = table.freeze({
			Q = 'й', W = 'ц', E = 'у', R = 'к', T = 'е', Y = 'н', U = 'г', I = 'ш', O = 'щ', P = 'з',
			LeftBracket = 'х', RightBracket = 'ъ',
			A = 'ф', S = 'ы', D = 'в', F = 'а', G = 'п', H = 'р', J = 'о', K = 'л', L = 'д',
			Semicolon = 'ж', Quote = 'э',
			Z = 'я', X = 'ч', C = 'с', V = 'м', B = 'и', N = 'т', M = 'ь',
			Comma = 'б', Period = 'ю', Backquote = 'ё'
		})
	}),
	-- Hebrew, Georgian
})

The whole rest of this post is about not treating a KeyCode as a character, because the enum is a position. Here I'm exploiting exactly that. The enum being a stable physical position is what lets one table serve every player on every layout. A US QWERTY player and an AZERTY player both press the key Enum.KeyCode.Q and both get й, which is where й lives on a real Russian keyboard.

TypingController.SetLanguage picks the table for the round and hands it to the resolver:

function TypingController.SetLanguage(lang: string)
	local language = GameConfigs.Round.LANGUAGES[lang]

	TypingController.ValidChars = languageChars(lang)
	TypingController.RTL = language ~= nil and language.RTL == true
	TypingController.KeyLayout = KeyboardLayouts[lang]
end

Three details worth calling out:

A player who does have a real Russian layout installed still works — the layout table returns й for that key, which is what their OS would have reported anyway.

The core UI was eating ю

Then the actual bug, the one that made me write this section.

Roblox binds the emote menu to .. On US QWERTY that's punctuation and nobody cares. Under ЙЦУКЕН that same physical key is ю, which is a letter, and a common one. In the layout table above it's right there: Period = 'ю'.

To solve this, the emotes menu is disabled during typing because the Core Roblox scripts are bound to that key's position. Quick and easy fix though: StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.EmotesMenu, false)

The general rule this taught me: any core hotkey sitting on punctuation in US QWERTY is a letter somewhere else. Roblox's core bindings are position-based like everything else, and they were chosen against a US keyboard. If your game is a typing game, those positions are not yours to donate.

Why not just use InputActions?

Admittedly, I don't have as much experience with InputActions (IAS) as I do with UserInputService. I did experiment with it though, and I couldn't find a use for it in this game. Not only did I not find a use for it in this game. I also highly do not think IAS should be use for any work unless its a prototype.

It seems to be a more beginner friendly way of handling user inputs and their actions. I mean, it's cool that you can set the priorities of actions, however, ContextActionService already provides such handling.

Considering my game, IAS is action-based, not character-based. There is no text output anywhere in it. To fake a TextBox with it you would need roughly 40+ Bool actions, and you would still have no shift/caps handling, no IME for CJK, no clipboard paste, no mobile keyboard, no autorepeat, no autocorrect and so on.

With UIS you get every input, you filter it yourself, and you manage it. IAS moves that into data. This feels like the release of Attributes, except Attributes were actually useful opposed to ValueObjects. There doesn't seem to be much freedom with IAS.

So: never treat a KeyCode as a character. Resolve it, normalize it once, in one place, and let every input path in your game go through that.

And when the OS can't give you characters from Cyrillic, Hebrew, Georgian on a machine that has no such layout installed, flip it around and use the position on purpose. Ship the layout yourself. Then go check which keys Roblox's core UI quietly took from you first, because on somebody's keyboard one of them is a letter.