Skip to content
RustPluginGenerator

Oxide Lang API: Localizing Rust Plugin Messages

The Oxide lang API stores plugin text in named keys: register English defaults in LoadDefaultMessages with lang.RegisterMessages, then read them with lang.GetMessage(key, this, userId) and fill placeholders with string.Format. Oxide writes the defaults to oxide/lang/en/<PluginName>.json, where server owners can edit them or add other languages, and each player is served in their own language.

5 min readUpdated Oct 26, 2026

The Oxide lang API lets a plugin keep every player-facing string in a keyed dictionary instead of in code. You register English defaults in LoadDefaultMessages with lang.RegisterMessages, fetch text with lang.GetMessage(key, this, userId), and Oxide handles the JSON files and per-player language selection. Carbon loads the same plugins and uses the same API.

How the pieces fit together

PiecePurpose
LoadDefaultMessages()Hook where you register the default (English) dictionary
lang.RegisterMessages(dict, this, lang)Stores a dictionary for a plugin; the language code defaults to en
lang.GetMessage(key, this, userId)Returns the text for a key in the player's language
oxide/lang/<lang>/<Name>.jsonOne file per language per plugin; editable by server owners

Carbon uses carbon/lang as the folder instead of oxide/lang.

Registering default messages

Register everything in LoadDefaultMessages. Oxide calls it before the plugin finishes loading, so messages are ready by Loaded and OnServerInitialized.

C#
protected override void LoadDefaultMessages()
{
    lang.RegisterMessages(new Dictionary<string, string>
    {
        ["NoPermission"] = "You do not have permission to use this command.",
        ["Welcome"] = "Welcome, {0}! There are {1} players online.",
        ["Reward"] = "You received <color=#ffcc00>{0}x {1}</color>."
    }, this);
}

Keys are case-sensitive identifiers. Use stable, descriptive names like NoPermission rather than the English sentence, so changing the wording never breaks the lookup.

Reading messages with a userId

The helper from the standard layout wraps lookup and formatting in one place:

C#
private string Msg(string key, string userId = null, params object[] args)
    => string.Format(lang.GetMessage(key, this, userId), args);

The third argument of lang.GetMessage is the user ID string. Oxide looks up that player's chosen language and returns the matching text, falling back to English when the language or key is missing from that file. Pass player.UserIDString for anything a player will read.

C#
[ChatCommand("hello")]
private void CmdHello(BasePlayer player, string command, string[] args)
{
    if (player == null) return;
    player.ChatMessage(Msg("Welcome", player.UserIDString, player.displayName, BasePlayer.activePlayerList.Count));
}

If you pass null, the server default language is used. That is right for console output and logs, wrong for player chat. For related command setup see chat commands vs console commands.

Never read a message before it is registered. A plugin that calls the lang API without registering anything produces the situation described in using lang API but no messages registered.

Placeholders and string.Format

Messages use .NET composite formatting: {0}, {1} and so on, numbered from zero in the order of the arguments you pass. The numbers are part of the contract between code and translation. A translator may reorder them ("{1} players, welcome {0}"), which is a main reason to use indexes instead of concatenation.

Rules to keep formatting safe:

  • Every index used in the text must have an argument. {2} with two arguments throws.
  • Extra arguments are harmless; unused ones are ignored.
  • Literal braces must be doubled: {{ and }}.
  • Format specifiers work: {0:N0} for thousands separators, {0:0.0} for one decimal.

The generated JSON files

When a plugin registers messages, Oxide writes oxide/lang/en/<PluginName>.json. It is a flat object of key to text:

JSON
{
  "NoPermission": "You do not have permission to use this command.",
  "Welcome": "Welcome, {0}! There are {1} players online."
}

Server owners edit this file to change wording without touching the plugin source. After editing, run oxide.reload <PluginName> so the plugin reloads the strings (see Oxide console commands). Because owners edit these files, never rely on the exact English wording in logic; compare keys or state, not text.

Per-player language

Each player has a language stored by Oxide. To ship extra languages, register another dictionary with a language code:

C#
protected override void LoadDefaultMessages()
{
    lang.RegisterMessages(new Dictionary<string, string>
    {
        ["Welcome"] = "Welcome, {0}! There are {1} players online."
    }, this);

    lang.RegisterMessages(new Dictionary<string, string>
    {
        ["Welcome"] = "Willkommen, {0}! Es sind {1} Spieler online."
    }, this, "de");
}

This creates oxide/lang/de/<PluginName>.json. A player whose language is de receives the German text; everyone else gets English. Keys missing from a translation fall back to English, so partial translations are safe. Owners can also add a language purely by creating the folder and file by hand. Plugins that let players pick a language usually just set it through Oxide's language handling; the lookup code in your plugin does not change.

Adding new keys in updates

When you release a new version with extra keys, existing servers already have a lang file from the old version. Oxide does not overwrite that file with your new defaults wholesale, so the added keys may be missing from the on-disk file. Practical consequences:

  • lang.GetMessage for a key that is not in the file returns the key name, which players see as raw text like NewFeatureMessage. Verify this on a server that already has the old file, not only on a clean install.
  • Tell users in your changelog which keys were added, or ask them to delete the lang file so it regenerates. Deleting loses their edits, so recommend a backup first.
  • Keep old keys while they might still be used; removing a key only matters to your own code.

Because a missing key degrades visibly instead of crashing, it is easy to spot during testing. The same advice applies to config changes, covered in the config file guide.

Colors and rich text

Rust chat and CUI labels render Unity rich text:

  • <color=#rrggbb>text</color> or named colors like <color=red>
  • <b>bold</b> and <i>italic</i>
  • <size=18>text</size> (works in UI text; chat support varies, so test it)

Guidelines:

  • Always close every tag. An unclosed <color> can tint the rest of the line.
  • Keep tags inside the dictionary value so owners can recolor without code changes.
  • Do not hard-code the plugin prefix color in several keys; use one placeholder such as {0} for a prefix or keep a Prefix key and combine in the helper.
  • Rich text in chat is rendered by the client; do not use it in console replies or logs, where it prints as literal tags.

For text drawn in panels, see CUI basics.

FormatException pitfalls

string.Format throws FormatException when the text and arguments disagree. The stack trace appears as a failed hook or failed command. The typical causes:

  1. Index too high. The message uses {1} but the call passes one argument. The error text is covered in FormatException: index must be greater than or equal to zero.
  2. Owner-edited file. A server owner adds {2} or removes {0} while translating. Guard against it by documenting each key's placeholders.
  3. Stray braces. A JSON-style or C#-style { in the text, such as "Use {player} to target", is parsed as a placeholder. Write {{player}} or avoid braces.
  4. Passing a string array. Passing args (a string[]) as a single parameter can bind differently than you expect. Pass individual values or args deliberately as params object[].

To make the helper resistant to bad edited files, catch the exception and fall back to the raw text:

C#
private string Msg(string key, string userId = null, params object[] args)
{
    string template = lang.GetMessage(key, this, userId);
    try { return string.Format(template, args); }
    catch (FormatException) { return template; }
}

This avoids breaking a hook such as OnPlayerConnected over a typo in a translation file. Do not log from the catch in frequently called paths; if you need a warning, emit it once at startup.

Complete example

C#
using System;
using System.Collections.Generic;

namespace Oxide.Plugins
{
    [Info("Lang Demo", "RustPluginGenerator", "1.0.0")]
    [Description("Greets players in their own language")]
    public class LangDemo : RustPlugin
    {
        private const string PermUse = "langdemo.use";

        private void Init()
        {
            permission.RegisterPermission(PermUse, this);
        }

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                ["NoPermission"] = "You do not have permission to use this command.",
                ["Greeting"] = "Hello, <color=#ffcc00>{0}</color>! {1} players are online."
            }, this);

            lang.RegisterMessages(new Dictionary<string, string>
            {
                ["NoPermission"] = "Dafuer fehlt dir die Berechtigung.",
                ["Greeting"] = "Hallo, <color=#ffcc00>{0}</color>! {1} Spieler sind online."
            }, this, "de");
        }

        private string Msg(string key, string userId = null, params object[] args)
        {
            string template = lang.GetMessage(key, this, userId);
            try { return string.Format(template, args); }
            catch (FormatException) { return template; }
        }

        [ChatCommand("greet")]
        private void CmdGreet(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                player.ChatMessage(Msg("NoPermission", player.UserIDString));
                return;
            }
            player.ChatMessage(Msg("Greeting", player.UserIDString, player.displayName, BasePlayer.activePlayerList.Count));
        }
    }
}

Grant the permission with oxide.grant group default langdemo.use (see permissions and groups). To see lang usage in a finished plugin, look at the welcome messages recipe, or build your own with the plugin generator.

FAQ

Why does a new message key not show up after I update my plugin?

Oxide only writes the default file when it is missing or when registered keys are absent from it. If a key you added still returns the key name, an old file or a typo is the usual cause. Check oxide/lang/en/<Name>.json, add the key manually if needed, then run oxide.reload <Name>.

How do I add a new language to my plugin?

Call lang.RegisterMessages a second time with a dictionary and a language code as the third argument, for example lang.RegisterMessages(ru, this, "ru"). Oxide stores it in oxide/lang/ru/<Name>.json. Server owners can also create that file by hand by copying the English one and translating the values.

What does lang.GetMessage return when a key does not exist?

It returns the key string itself rather than throwing. That makes missing keys visible in chat as raw key names, which is a good sign of a typo or a missing registration. A plugin that uses the lang API without registering any messages triggers a separate warning.

Can I use Rust rich text colors in lang messages?

Yes. Chat and UI text accept Unity rich text such as <color=#ff8800>text</color> and <b>. Keep the tags inside the translatable value so translators can move them, and always close them. Put the color tag around the placeholder content, not the whole sentence, when only the value should be colored.

Which language does the console or an RCON user get?

When you pass null as the userId, Oxide uses the server's default language, which is English unless changed. Console replies via arg.ReplyWith therefore use the default language. Pass a player's UserIDString whenever a specific player will read the message.

Skip the C#: describe your plugin

Open generator