All work

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 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)));
}
From the released code, trimmed to the registration path.

Working inside a game that wasn’t built for it

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.