Skip to content
RustPluginGenerator

How to Make a Rust Plugin: Oxide/Carbon C# Tutorial From Zero to a Working Plugin

A Rust plugin is a single C# file that you drop into oxide/plugins (or carbon/plugins) on a server running Oxide or Carbon; the server compiles and loads it automatically. This tutorial builds a complete playtime reward plugin step by step, covering hooks, chat commands, config, language messages, permissions, data files, reloading and reading compile errors.

13 min readUpdated Oct 26, 2026

To make a Rust plugin, you write one C# file containing a class that inherits RustPlugin, save it into the oxide/plugins folder (or carbon/plugins) of a server that runs Oxide or Carbon, and the server compiles and loads it on its own. There is no project to build and no DLL to ship. This guide takes you from an empty file to a finished playtime reward plugin with a hook, a chat command, a config file, translatable messages, a permission and a saved data file.

What an Oxide or Carbon plugin is

Oxide (also known as uMod) is a modding framework that patches the Rust server. At the points where the game does something interesting, such as a player connecting, an entity taking damage or a container being opened, the patched game code calls into Oxide. Oxide then looks for methods with a matching name in every loaded plugin and calls them. These calls are called hooks.

Carbon is a separate framework that loads Oxide plugins and accepts the same oxide.* console commands. A plugin written as described here works on both in the normal case.

A plugin therefore consists of:

  • A class named after the file, inheriting RustPlugin.
  • Hook methods that react to game events, such as OnPlayerConnected.
  • Optional commands, a config file, language files, permissions and data files.

The framework compiles the source on the server with Roslyn. This is why a plugin is distributed as a readable .cs file.

What you need

ItemPurpose
A Rust dedicated server with Oxide or CarbonRuns and compiles the plugin. See install Oxide on a Rust server.
A code editorVisual Studio Code or Visual Studio. Any text editor works, but C# highlighting helps.
Access to the server filesFTP, SFTP, a host panel or local disk access to the plugins folder.
Console or RCON accessTo run oxide.reload and read errors.
Optional: Rust server DLLsReferencing the files in RustDedicated_Data/Managed gives your editor autocomplete. See Rust plugin development setup.

You do not need the editor to compile successfully. The server is the compiler that matters, because it has the exact game assemblies for your Rust version.

Folder layout

Oxide and Carbon use similar layouts:

PurposeOxideCarbon
Plugin source filesoxide/pluginscarbon/plugins
Config filesoxide/config/<Name>.jsoncarbon/configs
Data filesoxide/datacarbon/data
Language filesoxide/lang/<lang>/<Name>.jsoncarbon/lang
Logsoxide/logscarbon/logs

The plugin you will write is called PlaytimeReward, so the file must be named PlaytimeReward.cs. The class name, file name and the generated config file name all share this name. If the file name and the main class do not match, the plugin does not load. See plugin filename must match main class.

Step 1: the minimal plugin

This is the smallest useful plugin. It prints a line when it loads.

C#
namespace Oxide.Plugins
{
    [Info("Playtime Reward", "RustPluginGenerator", "1.0.0")]
    [Description("Gives players a reward for time spent online")]
    public class PlaytimeReward : RustPlugin
    {
        private void Init()
        {
            Puts("PlaytimeReward is starting");
        }
    }
}

Save this as PlaytimeReward.cs in the plugins folder. Within a few seconds the server console prints a line like Loaded plugin Playtime Reward v1.0.0 by RustPluginGenerator. There is no separate "compiled successfully" message; the Loaded line is your confirmation.

What each part does:

  • namespace Oxide.Plugins is required. Oxide looks for the plugin class in this namespace.
  • [Info(title, author, version)] gives the plugin its readable name. The version must be in major.minor.patch form, otherwise you get version is invalid, should be major.minor.patch. A missing attribute causes Info attribute missing.
  • [Description] is a one-line summary shown by oxide.plugins.
  • RustPlugin is the base class for Rust plugins.
  • Init is a hook called once when the plugin loads. See Init.

Do not add a constructor to the main class. Oxide creates the instance itself, and a constructor triggers main plugin class should not have constructor. Use Init for setup instead.

Plugin lifecycle

Hooks about the plugin itself run in a fixed order:

  1. Init runs first. Register permissions and load data here.
  2. The config and language files load.
  3. Loaded runs.
  4. OnServerInitialized runs once the server is up. If your plugin loads while the server is already running, Oxide calls it immediately with initial set to false.
  5. Unload runs when the plugin is unloaded, reloaded or the server shuts down.

Start timers and touch players in OnServerInitialized, not in Init, because players and entities may not exist yet during Init on a server that is still booting.

Step 2: add a hook

A hook is a private method with the exact hook name and parameter types. Oxide matches by name, then checks that the arguments the game passes fit your parameter types. Methods can declare fewer parameters than the hook passes.

Here is a hook that welcomes players when they connect:

C#
private void OnPlayerConnected(BasePlayer player)
{
    if (player == null) return;
    player.ChatMessage("Welcome back!");
}

See OnPlayerConnected for when it fires. For the player side, OnPlayerDisconnected is the counterpart.

Three habits matter from the start:

  • Use the exact hook name. A typo compiles fine but is never called, because nothing in Oxide matches it.
  • Use the exact parameter types from the hook page. If you declare a more specific type than the game passes, such as BasePlayer where the hook sometimes passes another BaseCombatEntity, Oxide silently skips calls where the argument is a different type. That is sometimes useful and sometimes the reason a hook seems dead. The explanation is in Oxide hooks explained.
  • Check parameters for null before using them. Some arguments, like HitInfo.InitiatorPlayer, are legitimately null.

For hooks that return object, returning null keeps the default game behavior. Many cancelable hooks are patched so that any non-null return value, true or false, cancels the action. Never assume that returning true forces something to happen; read the specific hook page.

Hot hooks such as damage, gather and input fire very often. Avoid logging inside them and keep the work small. See Rust plugin performance.

Step 3: add a chat command

Chat commands use the [ChatCommand] attribute and a method with a fixed signature:

C#
[ChatCommand("playtime")]
private void CmdPlaytime(BasePlayer player, string command, string[] args)
{
    player.ChatMessage("Your playtime will show here");
}

Players type /playtime in chat. The args array holds the words after the command. For /playtime reset now, args would contain reset and now. Always check args.Length before reading an index; otherwise you will see failed to run command: IndexOutOfRangeException.

If you also want the command in the F1 console or RCON, use [ConsoleCommand] instead. The difference is explained in chat commands vs console commands. Registering a name that another plugin already owns logs tried to register command already exists.

Step 4: plan the playtime reward plugin

Now put the pieces together. The plugin will:

  • Run a timer every 60 seconds.
  • On each tick, add one minute to the stored playtime of every connected, awake player who holds the playtimereward.use permission.
  • Every N minutes (configurable), give the player an item, by default 10 scrap.
  • Offer /playtime to show total minutes and time until the next reward.
  • Save minutes to a data file and clear them on a map wipe.

This is a simplified version of the recipe in Playtime rewards. If you only want to measure time, see Playtime tracker. If you want to promote players to groups by time, see Playtime group promotion.

Step 5: the config file

Config values let a server owner change behavior without editing code. Use a nested class with [JsonProperty] fields that have defaults:

C#
private PluginConfig config;

private class PluginConfig
{
    [JsonProperty("Minutes between rewards")]
    public int IntervalMinutes = 30;

    [JsonProperty("Reward item shortname")]
    public string ItemShortname = "scrap";

    [JsonProperty("Reward amount")]
    public int Amount = 10;
}

protected override void LoadDefaultConfig() => config = new PluginConfig();

protected override void LoadConfig()
{
    base.LoadConfig();
    try
    {
        config = Config.ReadObject<PluginConfig>();
        if (config == null) throw new Exception();
    }
    catch
    {
        PrintWarning("Configuration file is invalid, using defaults");
        LoadDefaultConfig();
    }
    SaveConfig();
}

protected override void SaveConfig() => Config.WriteObject(config);

This needs using System; and using Newtonsoft.Json; at the top of the file. The first time the plugin loads, Oxide calls LoadDefaultConfig and writes oxide/config/PlaytimeReward.json. On later loads the file is read, and the try/catch falls back to defaults if the JSON is damaged. Calling SaveConfig() at the end writes any newly added fields back to disk, which is how old config files gain new options after an update.

A missing Newtonsoft.Json using line gives CS0246: JsonProperty not found. A broken file on disk produces failed to load config file (corrupt). More depth is in Rust plugin config file.

Step 6: language messages

Player-facing text should not be hard-coded in commands. Register it with the lang API so server owners can translate it:

C#
protected override void LoadDefaultMessages()
{
    lang.RegisterMessages(new Dictionary<string, string>
    {
        ["NoPermission"] = "You do not have permission to use this.",
        ["Reward"] = "You received {0}x {1} for your playtime.",
        ["Playtime"] = "Playtime: {0} minutes. Next reward in {1} minutes."
    }, this);
}

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

This needs using System.Collections.Generic;. LoadDefaultMessages creates oxide/lang/en/PlaytimeReward.json. Admins can edit that file or add other language folders, and Oxide picks the language by each player's setting when you pass their UserIDString to lang.GetMessage. The {0} placeholders are filled by string.Format in the helper. If you use the lang API without registering messages you get using lang API but no messages registered. The complete topic is in Rust plugin localization.

Step 7: permissions

Permissions decide who may use a feature. Declare them as constants, register them in Init, and check them when needed:

C#
private const string PermUse = "playtimereward.use";

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

// later
bool allowed = permission.UserHasPermission(player.UserIDString, PermUse);

The permission name must start with the lowercase class name followed by a dot. Otherwise Oxide logs a warning, see missing plugin name prefix for permission. Registering the same name twice gives duplicate permission registered.

Grant it from the server console:

Text
oxide.grant group default playtimereward.use
oxide.grant user 76561198000000000 playtimereward.use

oxide.show perms lists all registered permissions, and oxide.show perm playtimereward.use shows who holds one. Oxide has two default groups, default and admin. Permission lookups are not case-sensitive. The full model of users, groups and parents is in Oxide permissions and groups. If you grant a name that was never registered, you see permission doesn't exist.

Step 8: the data file

Config is for settings that an admin edits. Data is for values the plugin changes while running. Oxide provides Interface.Oxide.DataFileSystem for this; it reads and writes JSON under oxide/data.

C#
private StoredData data;

private class StoredData
{
    [JsonProperty("Minutes played")]
    public Dictionary<string, int> Minutes = new Dictionary<string, int>();
}

private void LoadData()
{
    try
    {
        data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name);
    }
    catch
    {
        data = null;
    }
    if (data == null) data = new StoredData();
}

private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject(Name, data);

This needs using Oxide.Core;. Name is the plugin name, PlaytimeReward, so the file is oxide/data/PlaytimeReward.json. Player ids are stored as strings (UserIDString) to keep the JSON simple and safe. Reading a damaged file throws, so the try/catch falls back to empty data instead of breaking the plugin; see JsonReaderException in data file.

Save at sensible moments: when the server saves (OnServerSave), on unload, and after important changes. Do not write the file on every tick. More patterns are in Rust plugin data files.

For wipes, OnNewSave is called when the server creates a new save file after a map wipe. It is the right place to reset data that should not carry over. See also the wipe day plugin checklist.

Step 9: the timer

Timers run code later or repeatedly. timer.Every(60f, () => ...) calls your code every 60 seconds and returns a Timer object. Keep that reference so you can destroy it in Unload; otherwise a reloaded plugin can leave behind an old timer.

C#
private Timer rewardTimer;

private void OnServerInitialized()
{
    rewardTimer = timer.Every(60f, TickMinute);
}

private void Unload()
{
    rewardTimer?.Destroy();
    SaveData();
}

If code inside a timer throws, Oxide logs Failed to run a <seconds> timer and destroys that timer, so it stops silently afterwards. See failed to run timer and Rust plugin timers. Null-check inside the callback.

The complete plugin

This is the final file. Save it as PlaytimeReward.cs.

C#
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;

namespace Oxide.Plugins
{
    [Info("Playtime Reward", "RustPluginGenerator", "1.0.0")]
    [Description("Gives players an item for every set number of minutes they spend online")]
    public class PlaytimeReward : RustPlugin
    {
        private const string PermUse = "playtimereward.use";

        private PluginConfig config;
        private StoredData data;
        private Timer rewardTimer;

        #region Config

        private class PluginConfig
        {
            [JsonProperty("Minutes between rewards")]
            public int IntervalMinutes = 30;

            [JsonProperty("Reward item shortname")]
            public string ItemShortname = "scrap";

            [JsonProperty("Reward amount")]
            public int Amount = 10;
        }

        protected override void LoadDefaultConfig() => config = new PluginConfig();

        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                config = Config.ReadObject<PluginConfig>();
                if (config == null) throw new Exception();
            }
            catch
            {
                PrintWarning("Configuration file is invalid, using defaults");
                LoadDefaultConfig();
            }
            SaveConfig();
        }

        protected override void SaveConfig() => Config.WriteObject(config);

        #endregion

        #region Data

        private class StoredData
        {
            [JsonProperty("Minutes played")]
            public Dictionary<string, int> Minutes = new Dictionary<string, int>();
        }

        private void LoadData()
        {
            try
            {
                data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name);
            }
            catch
            {
                data = null;
            }
            if (data == null) data = new StoredData();
        }

        private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject(Name, data);

        #endregion

        #region Messages

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                ["NoPermission"] = "You do not have permission to use this.",
                ["Reward"] = "You received {0}x {1} for your playtime.",
                ["Playtime"] = "Playtime: {0} minutes. Next reward in {1} minutes."
            }, this);
        }

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

        #endregion

        #region Hooks

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

        private void OnServerInitialized()
        {
            if (ItemManager.FindItemDefinition(config.ItemShortname) == null)
            {
                PrintWarning("Unknown reward item '" + config.ItemShortname + "', no rewards will be given");
            }
            rewardTimer = timer.Every(60f, TickMinute);
        }

        private void OnServerSave() => SaveData();

        private void OnNewSave(string filename)
        {
            data = new StoredData();
            SaveData();
        }

        private void Unload()
        {
            rewardTimer?.Destroy();
            if (data != null) SaveData();
        }

        #endregion

        #region Logic

        private void TickMinute()
        {
            foreach (var player in BasePlayer.activePlayerList)
            {
                if (player == null || !player.IsConnected || player.IsSleeping()) continue;
                if (!permission.UserHasPermission(player.UserIDString, PermUse)) continue;

                string id = player.UserIDString;
                int minutes;
                data.Minutes.TryGetValue(id, out minutes);
                minutes++;
                data.Minutes[id] = minutes;

                if (config.IntervalMinutes > 0 && minutes % config.IntervalMinutes == 0)
                {
                    GiveReward(player);
                }
            }
        }

        private void GiveReward(BasePlayer player)
        {
            var item = ItemManager.CreateByName(config.ItemShortname, config.Amount);
            if (item == null) return;

            string itemName = item.info != null ? item.info.displayName.english : config.ItemShortname;
            int amount = item.amount;
            player.GiveItem(item);
            player.ChatMessage(Msg("Reward", player.UserIDString, amount, itemName));
        }

        #endregion

        #region Commands

        [ChatCommand("playtime")]
        private void CmdPlaytime(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;

            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                player.ChatMessage(Msg("NoPermission", player.UserIDString));
                return;
            }

            int minutes;
            data.Minutes.TryGetValue(player.UserIDString, out minutes);

            int interval = Math.Max(1, config.IntervalMinutes);
            int remaining = interval - (minutes % interval);
            player.ChatMessage(Msg("Playtime", player.UserIDString, minutes, remaining));
        }

        #endregion
    }
}

How the final file works

  • Init registers the permission and loads data before anything else can use it.
  • OnServerInitialized checks that the configured shortname is a real item. ItemManager.FindItemDefinition returns null for an unknown shortname, so a typo in the config produces one warning at startup instead of silent failure every half hour. It then starts the 60-second timer.
  • TickMinute loops over BasePlayer.activePlayerList, skips sleeping or disconnected players, requires the permission, increments the stored minutes and rewards at each multiple of the interval. Nothing is logged here because it runs repeatedly.
  • GiveReward creates the item and passes it to player.GiveItem, which puts it into the inventory or drops it near the player if there is no room. It reads the item name before giving, because the item can be merged into an existing stack.
  • /playtime checks the permission, then shows total minutes and the minutes remaining until the next reward.
  • OnServerSave and Unload persist data, and OnNewSave wipes it on a new map.
  • The #region blocks are only for readability.

Testing with oxide.reload

You do not restart the server to test. Oxide watches the plugins folder, and saving a changed .cs file reloads it automatically. You can also control it by hand from the server console or RCON:

CommandEffect
oxide.pluginsLists loaded plugins with title, version, author and file
oxide.reload PlaytimeRewardRecompiles and reloads one plugin
oxide.unload PlaytimeRewardUnloads it
oxide.load PlaytimeRewardLoads it
oxide.reload *Reloads everything
oxide.versionPrints the framework version

A test routine for this plugin:

  1. Upload the file and wait for the Loaded plugin Playtime Reward line.
  2. Run oxide.grant user <yourSteamId> playtimereward.use.
  3. Open oxide/config/PlaytimeReward.json and set Minutes between rewards to 1.
  4. Run oxide.reload PlaytimeReward.
  5. Stay awake in game for a minute and confirm the scrap arrives.
  6. Type /playtime and check the numbers.
  7. Set the interval back to your real value and reload again.

Remember that sleeping players are skipped, and that you must hold the permission. Both are frequent reasons a test seems to fail. More commands are listed in Oxide console commands.

Reading errors

Compile errors

If the code has a syntax or type error, Oxide prints Error while compiling PlaytimeReward: followed by numbered messages in the form [1] <message> at <line>:<column>. The CS code in the message is the Roslyn error number. The same text goes to the logs folder. A failed file is skipped, and the other plugins still compile and run.

Use the line and column to jump to the problem, then search the CS code. The most common beginner errors:

Message codeUsual causePage
CS1002Missing semicolonCS1002
CS1513Missing closing braceCS1513
CS0103Misspelled name or missing variableCS0103
CS0246Missing using or unknown typeCS0246
CS1061Member does not exist on that typeCS1061
CS0104Bare Random is ambiguousCS0104
CS0161Not every path returns a valueCS0161

For the ambiguous Random, write UnityEngine.Random.Range(...) in full.

Runtime errors

These appear after a successful load:

  • Failed to call hook 'X' on plugin 'Name vVersion' (...) means one of your hook methods threw an exception. The message and stack trace identify the type and line. The usual causes are null references (NullReferenceException) and bad casts (InvalidCastException).
  • Failed to initialize plugin means Init or Loaded threw, for example by reading a missing key. See failed to initialize plugin.
  • Failed to run command '/name' on plugin means a chat command threw.
  • Failed to run a N timer in 'Name vVersion' means a timer callback threw and the timer is gone.
  • Calling 'Hook' on 'Name vVersion' took Nms appears when a hook takes longer than 100 ms. See took ms hook performance warning.

A broader method is explained in debugging Rust plugins.

Publishing to your server

When the plugin works on your test server:

  1. Copy PlaytimeReward.cs to the live server's oxide/plugins (or carbon/plugins) folder. The server compiles and loads it within seconds, and you do not need a restart.
  2. Check the console for the Loaded line, and for any warnings.
  3. Grant the permission to the right group, for example oxide.grant group default playtimereward.use for everybody.
  4. Edit oxide/config/PlaytimeReward.json, then run oxide.reload PlaytimeReward so the values apply.
  5. Keep a copy of the source in version control or a backup folder. The data file lives in oxide/data; back that up too.

After a Rust update, game methods sometimes change and plugins may stop compiling or hooks may stop firing. Watch the monthly hook change reports and read fix Rust plugins after update. For third-party plugins, see how to install Rust plugins.

Safe habits for new plugin authors

  • Keep one top-level class per file, named like the file.
  • Do not make web requests or touch the file system directly. Use Config and Interface.Oxide.DataFileSystem for persistence. More on this in Rust plugin security.
  • Check permissions in commands, and never rely on hiding a command.
  • Put all player-facing text in the lang file.
  • Clean up in Unload: destroy timers, save data, and remove any UI you showed.
  • Avoid heavy work in frequently called hooks such as OnEntityTakeDamage, OnPlayerInput and OnTick.

Where to go next

Once this plugin works, extend it with one concept at a time:

If you would rather start from generated code and learn by editing it, the plugin generator produces files that follow the same structure used here.

FAQ

Do I need to know C# to make a Rust plugin?

Basic C# is enough to start: classes, methods, fields, lists and dictionaries. Most plugins are small and follow the same patterns shown in this guide. You learn the Rust-specific part, such as BasePlayer, Item and hook names, by reading hook pages and copying working structure. The AI plugin generator can also produce a starting file.

Do I have to compile the plugin myself?

No. Oxide and Carbon compile the .cs file on the server when it loads. You only upload source code. A compile error is printed to the server console and the file is skipped while other plugins keep working. Fix the file, save it again, and it reloads on its own.

Can the same plugin file run on Oxide and Carbon?

Yes for ordinary plugins. Carbon loads Oxide-style plugin files, so a class that inherits RustPlugin and uses Oxide hooks usually works unchanged on both. The folders differ: oxide/plugins on Oxide and carbon/plugins on Carbon. See Oxide vs Carbon for the differences.

Why does my plugin load but nothing happens?

The most common causes are a hook method with the wrong name or parameter types, a missing permission grant, or a timer that was never started. Oxide only calls methods that match a real hook. Compare your signature with the hook page, and check the console for a Failed to call hook message. See debugging Rust plugins.

Where should I test a new plugin?

Test on a local or staging server, not your live one. A plugin that gives items or edits damage can affect players immediately. Run a second Rust dedicated server with Oxide installed, join it, and use oxide.reload after each change. Move the file to production only when it behaves correctly.

Skip the C#: describe your plugin

Open generator