Your first plugin
You will build VisitTracker: it counts how often each player joined (as a component, so the counts survive hot reloads), blocks chat messages with banned words, answers /visits, and logs a summary every minute. The code blocks marked compile are built against the real API by the docs build (npm run verify:snippets).
1. Create the project
Section titled “1. Create the project”dotnet new ramjet-plugin -n VisitTrackercd VisitTracker(Set RAMJET_PLUGINS as described in create a plugin.) Replace the generated PluginMain.cs with this:
using Mc.Scripting;using Mc.Scripting.Commands;
namespace VisitTracker;
/// <summary>Per-player data stored in the ECS: a plain unmanaged struct. Values survive hot reloads.</summary>public struct Visits{ public int Count; public long LastJoinTick;}
[PluginInfo("VisitTracker", Version = "1.0.0", Author = "you", Description = "Counts joins, filters chat, answers /visits")]public sealed class PluginMain : Plugin{ private static readonly string[] BlockedWords = ["spam", "free-diamonds"];
private Query<Visits> _visits = null!; private long _tick; private int _joinsSinceEnable;
protected override void OnEnable() { // 1. A component: register the type under a global key, create a query over it once. Ecs.RegisterComponent<Visits>("visittracker:visits"); _visits = Ecs.Query<Visits>();
// 2. Join: add or update the component on the player's entity. Events.On((ref PlayerJoinEvent e) => { Entity player = e.Player; // Player has Get/Set/TryGet; GetOrAdd lives on Entity ref var visits = ref player.GetOrAdd<Visits>(); // a ref into ECS storage: use it right away visits.Count++; visits.LastJoinTick = _tick; _joinsSinceEnable++; Log.Info($"{e.Username.ToString()} joined (visit #{visits.Count})"); // joins are rare: allocating is fine });
// 3. Chat: cancel messages with a banned word. High priority runs after Normal handlers. Events.On((ref PlayerChatEvent e) => { foreach (string word in BlockedWords) { if (e.Message.Contains(word, StringComparison.OrdinalIgnoreCase)) { e.Cancelled = true; return; } } }, EventPriority.High);
// 4. A slash command: the name is matched for you, the body runs for the player who typed it. Commands.Register(Command.Literal("visits") .Executes(ctx => { Player player = ctx.RequirePlayer(); // the console has no visits: fails with a message int count = player.TryGet<Visits>(out var visits) ? visits.Count : 0; ctx.Reply($"You have joined {count} time(s) since tracking started."); return 1; }), description: "Show how often you joined");
// 5. A system: runs every tick. Here it only counts ticks, so we can timestamp joins. Systems.Add("tick_counter", Phase.PreTick, _ => _tick++);
// 6. A repeating task: once a minute (20 ticks = 1 second). Scheduler.RunEvery(20 * 60, () => Log.Info($"{_visits.Count()} tracked player(s), {_joinsSinceEnable} joins since this version was enabled"));
Log.Info("enabled"); }
protected override void OnDisable() => Log.Info("disabled");}2. Build and run
Section titled “2. Build and run”dotnet build -c ReleaseStart a scratch server (see build and hot reload) and watch the log. Loading the plugin prints:
[INFO] [VisitTracker] enabledJoin with a bot from the repository’s headless load-test client (the same section explains how to start it) to see the event handler run:
[INFO] [VisitTracker] Bot0 joined (visit #1)Type /visits in a game client connected to the server and the reply is the count from the component.
3. What each part does
Section titled “3. What each part does”Ecs.RegisterComponent<Visits>("visittracker:visits")binds your struct to a global key and creates the flecs component. The struct must be unmanaged (no strings or arrays) and owned by this plugin. See entities and the ECS.player.GetOrAdd<Visits>()(on theEntitybehind the player) returns arefinto storage. It is only valid until the next structural change, so it is used immediately and never stored.Playeritself offersGet,TryGet,Has,SetandRemove.Events.On((ref PlayerChatEvent e) => ...): events are structs passed byref.e.Cancelled = trueis how a handler vetoes.EventPriority.Highruns afterNormal. A handler does not run for an event that an earlier handler cancelled, unless you passreceiveCancelled: true.e.Username.ToString(): the event’s strings are borrowedUtf8Stringvalues. CallToString()before you interpolate one (see Utf8String).e.Message.Contains(...)works on it directly.Commands.Register(Command.Literal("visits")...)registers a command node in the server’s command tree.ctx.Replyanswers the player who typed it. See commands, and commands with arguments for typed arguments.Systems.Add("tick_counter", Phase.PreTick, ...)registers a system in the engine’s pipeline asVisitTracker:tick_counter. See systems.Scheduler.RunEveryschedules the log line; it is cancelled automatically on unload. See scheduler.Logprefixes every line with the plugin name.
4. Hot reload it
Section titled “4. Hot reload it”Keep a bot connected for a while, then, with the server still running, change Log.Info("enabled") to say something else (for example "enabled (v2)") and run dotnet build -c Release again. The log shows the new version enabled and the old one disabled:
[INFO] hot reloading VisitTracker.dll[INFO] [VisitTracker] enabled (v2)[INFO] [VisitTracker] disabledWhen the next minute log line comes, it reads:
[INFO] [VisitTracker] 1 tracked player(s), 0 joins since this version was enabledThe player who joined before the reload is still tracked: the Visits component sits on its entity in the ECS and its layout did not change. _joinsSinceEnable is back to 0, because it is a field of the plugin object, which is a new instance after every reload. That difference, state in components survives, state in fields does not, is the most important thing to internalise about hot reload. (A player who disconnects loses their entity and the component with it; to keep counts across sessions, write them to disk keyed by the player’s UUID, see config and data.)
5. Try next
Section titled “5. Try next”- Make the banned words configurable: config and data.
- Add arguments and tab completion: commands with arguments.
- A bigger plugin with config, permissions and commands: the homes plugin.
- Coming from Paper? The migration table maps every concept.