Skip to content

A homes plugin with config and permissions

Plugin: Homes. Players save named homes and teleport back to them.

Command Does Permission
/sethome [name] saves the current position as name (default home) homes.set
/home [name] teleports to a home, with a cooldown homes.use
/delhome <name> deletes a home homes.set
/homes lists your homes homes.use

Limit: MaxHomes per player from config; the permission homes.unlimited (default: operators) lifts it.

plugin.json
{
"id": "homes",
"name": "Homes",
"version": "1.0.0",
"api": "1.0",
"main": "Homes.PluginMain",
"authors": ["you"],
"description": "Set and teleport to homes.",
"permissions": [
{ "node": "homes.use", "default": "true", "description": "Use /home and /homes" },
{ "node": "homes.set", "default": "true", "description": "Use /sethome and /delhome" },
{ "node": "homes.unlimited", "default": "op", "description": "No limit on the number of homes" }
]
}

The loader registers the manifest’s permissions with their defaults and descriptions before OnEnable runs, so a permissions plugin can list and grant them from the start.

HomesConfig.cs
using System.Text.Json.Serialization;
namespace Homes;
/// <summary>plugins/homes/config.json: written with these defaults on first start, comments allowed.</summary>
public sealed class HomesConfig
{
public int MaxHomes { get; set; } = 3;
public int CooldownSeconds { get; set; } = 5;
}
/// <summary>One saved location.</summary>
public sealed class Home
{
public string World { get; set; } = "minecraft:overworld";
public double X { get; set; }
public double Y { get; set; }
public double Z { get; set; }
public float Yaw { get; set; }
public float Pitch { get; set; }
}
/// <summary>homes.json: player uuid to home name to home.</summary>
public sealed class HomesData
{
public Dictionary<string, Dictionary<string, Home>> Players { get; set; } = [];
}
// System.Text.Json source generation: no reflection at run time
[JsonSerializable(typeof(HomesConfig))]
[JsonSerializable(typeof(HomesData))]
[JsonSourceGenerationOptions(WriteIndented = true)]
internal sealed partial class HomesJson : JsonSerializerContext { }
PluginMain.cs
using System.Text.Json;
namespace Homes;
public sealed class PluginMain : Plugin
{
private Config<HomesConfig> _config = null!;
private HomesData _data = new();
private readonly Dictionary<Uuid, long> _lastTeleport = new(); // Environment.TickCount64 values (milliseconds)
protected override void OnEnable()
{
_config = Config.Load(HomesJson.Default.HomesConfig); // creates config.json with the defaults
_data = LoadData();
Commands.Register(Command.Literal("sethome")
.Permission("homes.set")
.Executes(ctx => SetHome(ctx, "home"))
.Then(Command.Argument("name", Arg.Word())
.Executes(ctx => SetHome(ctx, ctx.GetString("name")))),
description: "Save your position as a home");
Commands.Register(Command.Literal("home")
.Permission("homes.use")
.Executes(ctx => GoHome(ctx, "home"))
.Then(Command.Argument("name", Arg.Word())
.Suggests(OwnHomes) // tab completion of the player's own homes
.Executes(ctx => GoHome(ctx, ctx.GetString("name")))),
aliases: ["h"], description: "Teleport to a home");
Commands.Register(Command.Literal("delhome")
.Permission("homes.set")
.Then(Command.Argument("name", Arg.Word())
.Suggests(OwnHomes)
.Executes(DeleteHome)),
description: "Delete a home");
Commands.Register(Command.Literal("homes")
.Permission("homes.use")
.Executes(ListHomes),
description: "List your homes");
// a player who leaves starts with a fresh cooldown next time
Events.On((ref PlayerQuitEvent e) => _lastTeleport.Remove(e.Uuid));
Log.Info($"enabled, limit {_config.Value.MaxHomes} homes");
}
// ---- commands -----------------------------------------------------------------------------------------
private int SetHome(CommandContext ctx, string name)
{
Player player = ctx.RequirePlayer();
var homes = HomesOf(player, create: true)!;
bool exists = homes.ContainsKey(name);
int limit = _config.Value.MaxHomes;
if (!exists && homes.Count >= limit && !player.HasPermission("homes.unlimited"))
throw new CommandFailure($"You already have {homes.Count} homes (limit {limit}). Delete one with /delhome.");
Location at = player.Location;
homes[name] = new Home { World = at.World.Key, X = at.X, Y = at.Y, Z = at.Z, Yaw = at.Yaw, Pitch = at.Pitch };
Save();
ctx.Reply(exists ? $"Home '{name}' moved." : $"Home '{name}' saved.");
return homes.Count;
}
private int GoHome(CommandContext ctx, string name)
{
Player player = ctx.RequirePlayer();
var homes = HomesOf(player, create: false);
if (homes is null || !homes.TryGetValue(name, out var home))
throw new CommandFailure($"You have no home called '{name}'. Use /homes to list them.");
long now = Environment.TickCount64;
long cooldownMs = _config.Value.CooldownSeconds * 1000L;
if (_lastTeleport.TryGetValue(player.Uuid, out long last) && now - last < cooldownMs)
throw new CommandFailure($"Wait {(cooldownMs - (now - last) + 999) / 1000} more seconds.");
World world = Server.GetWorld(home.World) ?? throw new CommandFailure($"The world '{home.World}' is not loaded.");
// a Location carries its dimension: Teleport also moves the player to another world
if (!player.Teleport(new Location(world, home.X, home.Y, home.Z, home.Yaw, home.Pitch)))
throw new CommandFailure("The teleport was cancelled.");
_lastTeleport[player.Uuid] = now;
ctx.Reply($"Welcome home, '{name}'.");
return 1;
}
private int DeleteHome(CommandContext ctx)
{
Player player = ctx.RequirePlayer();
string name = ctx.GetString("name");
var homes = HomesOf(player, create: false);
if (homes is null || !homes.Remove(name)) throw new CommandFailure($"You have no home called '{name}'.");
Save();
ctx.Reply($"Home '{name}' deleted.");
return 1;
}
private int ListHomes(CommandContext ctx)
{
Player player = ctx.RequirePlayer();
var homes = HomesOf(player, create: false);
if (homes is null || homes.Count == 0) throw new CommandFailure("You have no homes. Use /sethome.");
ctx.Reply($"Homes ({homes.Count}): {string.Join(", ", homes.Keys.Order())}");
return homes.Count;
}
private IEnumerable<string> OwnHomes(CommandContext ctx) =>
ctx.Player is { } p && HomesOf(p, create: false) is { } homes ? homes.Keys.Order() : [];
// ---- data -----------------------------------------------------------------------------------------------
private Dictionary<string, Home>? HomesOf(Player player, bool create)
{
string key = player.Uuid.ToString();
if (_data.Players.TryGetValue(key, out var homes)) return homes;
if (!create) return null;
homes = new Dictionary<string, Home>(StringComparer.OrdinalIgnoreCase);
_data.Players[key] = homes;
return homes;
}
private HomesData LoadData()
{
string? json = Config.ReadText("homes.json");
if (json is null) return new HomesData();
try
{
var data = JsonSerializer.Deserialize(json, HomesJson.Default.HomesData) ?? new HomesData();
// home names compare without regard to case: /home Base and /home base are the same home
foreach (var key in data.Players.Keys.ToList())
data.Players[key] = new Dictionary<string, Home>(data.Players[key], StringComparer.OrdinalIgnoreCase);
return data;
}
catch (JsonException e)
{
string path = Path.Combine(DataFolder, "homes.json");
File.Move(path, path + ".bad", overwrite: true); // keep the damaged file for the admin
Log.Error("homes.json is damaged, starting with no homes (the file is kept as homes.json.bad)", e);
return new HomesData();
}
}
private void Save() =>
Config.WriteText("homes.json", JsonSerializer.Serialize(_data, HomesJson.Default.HomesData)); // atomic: temp file + rename
}

This is the tree the four commands describe (nodes in italics are arguments; the orange chips are the permission each node requires). Switch to usage lines for the forms players see.

Homes commands

  • sethomerunsneeds homes.setsaves as "home"
  • <name>string: type=wordruns
  • homerunsneeds homes.usealias: /h
  • <name>string: type=wordrunssuggests your own homes
  • delhomeneeds homes.set
  • <name>string: type=wordrunssuggests your own homes
  • homesrunsneeds homes.use
  • Permissions: with no permissions plugin, defaults apply: everyone can use /home and /sethome; only operators have homes.unlimited. A permissions backend can grant or deny any node per player or group, and the commands appear or disappear in the client’s command tree accordingly.
  • Config: edit plugins/homes/config.json. Call _config.Reload() (or set _config.Watch = true in OnEnable) to pick up edits without a restart; a parse error keeps the previous values and logs the line. Values are read through _config.Value at the time of use.
  • Hot reload: rebuild the plugin and the commands and permissions are registered again by the new OnEnable; the homes are re-read from disk; the cooldown dictionary starts empty (it is not worth persisting). Nothing is lost because Save() writes through on every change.
  • Why milliseconds for the cooldown: Environment.TickCount64 measures wall-clock time, so the cooldown lasts the configured seconds even when the server runs behind. Use Server.CurrentTick instead if the cooldown should follow the server’s tick rate.
  • Why Dictionary<string, ...> keyed by the UUID string: JSON object keys are strings; the UUID is the stable identity of a player (names change).