Released openly
Dojo Arena: a V Rising server mod
A PvP arena mod for V Rising that ran a public server for about six months. I rewrote the codebase and built its command framework, working inside a Unity ECS game through IL2CPP. When the server closed, we released the mod openly so other servers could use it.
- Role
- Rewrote the codebase, designed the command framework
- Stack
- C#, .NET 6, BepInEx (IL2CPP), Harmony, Unity ECS
- Context
- Public PvP arena server, co-developed with one other developer
- Status
- Released openly for other servers
The problem
V Rising is a survival game. An arena server needs the opposite: no loot loss, no base building, instant respawns, and on-demand gear so every fight starts on equal terms. None of that is exposed for modding. The game is compiled to native code through IL2CPP and built on Unity’s entity component system, so every change meant finding the right system in the decompiled game and hooking into it.
The framework underneath
- Commands declared by attribute. A command is a static method with one attribute carrying its name, usage, aliases and permission. At load the framework finds them by reflection, checks each signature, and rejects bad declarations and name or alias collisions with a log entry rather than failing when a player types the command.
- Admin-only by default. A new command can only be used by admins unless it explicitly opts out, so forgetting a flag can’t open an admin tool to every player.
- A quote-aware parser so arguments like spell and item names with spaces work.
- An event bus with priorities, so modules subscribe to load, connect and similar events without knowing about each other.
- Timers driven by the game’s own update loop, with errors isolated per timer so one failing callback can’t stop the others.
// Commands are plain static methods. The attribute is the whole registration.
[Command("j", "<spell name> [mods: 132|1,3,2|13,2|?]", "Spawns a spell-enhancing jewel with the specified mods.", AdminOnly = false, Aliases = new[] { "jewel" })]
public static string JewelCommand(Player player, List<string> args) { /* ... */ }
[Command("kick", "<partOfName>", "Kick the specified player out of the server.", AdminOnly = true)]
public static string KickCommand(Player player, string fullArgText) { /* ... */ }
public class CommandAttribute : Attribute
{
public string Name = "";
public string Usage = "";
public string Description = "";
public string[] Aliases = null;
public bool AdminOnly = true; // admin-only unless a command opts out
public bool Hidden = false;
public CommandParameters Parameters = CommandParameters.Invalid;
public MethodInfo Method;
}
// Signatures are checked once at load, so a badly declared command is
// rejected with a log entry instead of failing when a player uses it.
public static bool ValidateCommandMethod(CommandAttribute attribute)
{
var paramList = attribute.Method.GetParameters();
switch (paramList.Length)
{
case 0:
attribute.Parameters = CommandParameters.None;
return true;
case 1:
if (paramList[0].ParameterType == typeof(Player))
{
attribute.Parameters = CommandParameters.Player;
return true;
}
return false;
case 2:
if (paramList[0].ParameterType != typeof(Player)) return false;
if (paramList[1].ParameterType == typeof(List<string>))
{
attribute.Parameters = CommandParameters.PlayerAndArgs;
return true;
}
else if (paramList[1].ParameterType == typeof(string))
{
attribute.Parameters = CommandParameters.PlayerAndFullArgText;
return true;
}
return false;
default:
return false;
}
}
[EventSubscriber(Events.Load)]
public static void RegisterCommands()
{
if (!G.MethodsWithAttributes.ContainsKey(typeof(CommandAttribute))) return;
foreach ((var commandMethod, var attribute) in G.MethodsWithAttributes[typeof(CommandAttribute)])
{
var commandAttribute = (CommandAttribute)attribute;
commandAttribute.Method = commandMethod;
commandAttribute.Name = commandAttribute.Name.ToLower().Replace(" ", "");
var name = commandAttribute.Name;
if (string.IsNullOrWhiteSpace(name)) continue;
if (!ValidateCommandMethod(commandAttribute))
{
Output.LogError("Command-ValidationFailed", $"Command {name} has failed method validation and may be inappropriately defined.", $"MethodName = {commandMethod.Name}");
continue;
}
if (Lookup.ContainsKey(name))
{
Output.LogError("Command-NameTaken", $"The command name '{name}' was already taken in lookup by a pre-existing command!",
$"Method={commandAttribute.Method.Name}, Usage={commandAttribute.Usage}, Desc={commandAttribute.Description}");
continue;
}
Lookup.Add(name, commandAttribute);
SortedList.Add(commandAttribute);
if (commandAttribute.Aliases != null)
{
foreach (var alias in commandAttribute.Aliases)
{
if (Lookup.ContainsKey(alias))
{
Output.LogError("Command-AliasTaken", $"Command '{name}'s alternative alias '{alias}' in lookup by a pre-existing command!",
$"Method={commandAttribute.Method.Name}, Usage={commandAttribute.Usage}, Desc={commandAttribute.Description}");
continue;
}
Lookup.Add(alias, commandAttribute);
}
}
}
SortedList.Sort((x, y) => (x.Name.CompareTo(y.Name)));
}Working inside a game that wasn’t built for it
- Instant respawns. The game’s respawn routine isn’t callable from mod code, so the mod finds the right overload in the IL2CPP runtime by reflection and invokes it directly.
- Arena rules as system patches. Building, item drops and death handling are intercepted at the ECS system level through Harmony, so the rules hold no matter how a player triggers them.
- Gear on demand. Rather than hand-maintaining a list of every jewel and spell modifier, the mod extracts them from the game’s own data into a config file. A command then builds a jewel with up to three validated modifiers, so players get exactly the build they want and nothing the game wouldn’t allow.
- Low-level workarounds where the IL2CPP bindings fell short, including handling a native nullable struct with unsafe code.
Outcome
The mod ran a public arena server for about six months. When the server closed, we released it openly so other communities could run their own arenas with it.
If I built it again
I’d bind command arguments to typed parameters instead of passing raw strings, so each command stops re-parsing its own input, and I’d add tests around the parser and the system patches. A game update can quietly change the systems a mod depends on, and tests are the cheapest way to find out before players do.