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
| Item | Purpose |
|---|---|
| A Rust dedicated server with Oxide or Carbon | Runs and compiles the plugin. See install Oxide on a Rust server. |
| A code editor | Visual Studio Code or Visual Studio. Any text editor works, but C# highlighting helps. |
| Access to the server files | FTP, SFTP, a host panel or local disk access to the plugins folder. |
| Console or RCON access | To run oxide.reload and read errors. |
| Optional: Rust server DLLs | Referencing 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:
| Purpose | Oxide | Carbon |
|---|---|---|
| Plugin source files | oxide/plugins | carbon/plugins |
| Config files | oxide/config/<Name>.json | carbon/configs |
| Data files | oxide/data | carbon/data |
| Language files | oxide/lang/<lang>/<Name>.json | carbon/lang |
| Logs | oxide/logs | carbon/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.
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.Pluginsis required. Oxide looks for the plugin class in this namespace.[Info(title, author, version)]gives the plugin its readable name. The version must be inmajor.minor.patchform, 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 byoxide.plugins.RustPluginis the base class for Rust plugins.Initis 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:
Initruns first. Register permissions and load data here.- The config and language files load.
- Loaded runs.
- OnServerInitialized runs once the server is up. If your plugin loads while the server is already running, Oxide calls it immediately with
initialset to false. - 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:
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
BasePlayerwhere the hook sometimes passes anotherBaseCombatEntity, 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:
[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.usepermission. - Every N minutes (configurable), give the player an item, by default 10 scrap.
- Offer
/playtimeto 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:
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:
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:
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:
oxide.grant group default playtimereward.use
oxide.grant user 76561198000000000 playtimereward.useoxide.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.
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.
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.
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
Initregisters the permission and loads data before anything else can use it.OnServerInitializedchecks that the configured shortname is a real item.ItemManager.FindItemDefinitionreturns 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.TickMinuteloops overBasePlayer.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.GiveRewardcreates the item and passes it toplayer.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./playtimechecks the permission, then shows total minutes and the minutes remaining until the next reward.OnServerSaveandUnloadpersist data, andOnNewSavewipes it on a new map.- The
#regionblocks 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:
| Command | Effect |
|---|---|
oxide.plugins | Lists loaded plugins with title, version, author and file |
oxide.reload PlaytimeReward | Recompiles and reloads one plugin |
oxide.unload PlaytimeReward | Unloads it |
oxide.load PlaytimeReward | Loads it |
oxide.reload * | Reloads everything |
oxide.version | Prints the framework version |
A test routine for this plugin:
- Upload the file and wait for the
Loaded plugin Playtime Rewardline. - Run
oxide.grant user <yourSteamId> playtimereward.use. - Open
oxide/config/PlaytimeReward.jsonand setMinutes between rewardsto1. - Run
oxide.reload PlaytimeReward. - Stay awake in game for a minute and confirm the scrap arrives.
- Type
/playtimeand check the numbers. - 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 code | Usual cause | Page |
|---|---|---|
| CS1002 | Missing semicolon | CS1002 |
| CS1513 | Missing closing brace | CS1513 |
| CS0103 | Misspelled name or missing variable | CS0103 |
| CS0246 | Missing using or unknown type | CS0246 |
| CS1061 | Member does not exist on that type | CS1061 |
| CS0104 | Bare Random is ambiguous | CS0104 |
| CS0161 | Not every path returns a value | CS0161 |
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 pluginmeansInitorLoadedthrew, for example by reading a missing key. See failed to initialize plugin.Failed to run command '/name' on pluginmeans 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 Nmsappears 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:
- Copy
PlaytimeReward.csto the live server'soxide/plugins(orcarbon/plugins) folder. The server compiles and loads it within seconds, and you do not need a restart. - Check the console for the Loaded line, and for any warnings.
- Grant the permission to the right group, for example
oxide.grant group default playtimereward.usefor everybody. - Edit
oxide/config/PlaytimeReward.json, then runoxide.reload PlaytimeRewardso the values apply. - 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
ConfigandInterface.Oxide.DataFileSystemfor 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:
- Add a second hook, such as OnPlayerDisconnected, to save data when a player leaves.
- Show a small panel with Rust CUI basics.
- Pay rewards through an economy plugin using calling other plugins.
- Learn how entities, items and players are modeled in Rust entities and prefabs, Rust items and inventories and Rust player basics.
- Browse finished ideas in utility plugins and quality-of-life plugins.
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.
More guides
- CovalencePlugin vs RustPlugin: Differences and Which to Use4 min read
- How to Debug a Rust Oxide Plugin: Compile Errors, Exceptions and Slow Hooks10 min read
- How to Give Items to Players in a Rust Plugin (Oxide/Carbon)5 min read
- How to Install Oxide on a Rust Dedicated Server (Windows and Linux)7 min read
Skip the C#: describe your plugin
Open generator