Command Registry

The data core for a developer console — command table, tokenizer, execution, history, and autocomplete. You build the UI.

Everything a developer console needs except the screen — commands, parsing, history, and autocomplete.

InterfaceICommandRegistry
Off switchNullCommandRegistry
AssemblyCommonGameSystem.Core
StartupRegistered automatically at boot (one of the 23 services) — no setup needed

What it does

The Command Registry is the engine a developer console, cheat menu, or automation script plugs into. It owns the data: a command table (name to handler), a tokenizer for raw input lines (the piece that splits give sword 3 into give, sword, 3), a bounded command history, and prefix autocomplete. It draws nothing on screen — the console UI (rendering, input capture, the toggle key) is yours to build, and the Build your own console section below shows you how.

Runtime problems never crash the caller. An unknown command, bad arguments, or a handler that throws is isolated, logged, and returned as a failed CommandResult. Only programming errors throw: registering a null or empty name, a null handler, using the registry after it is disposed, or calling it from a worker thread.

Quick start

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyCheats : MonoBehaviour
{
    private ICommandRegistry _commands;

    private void Awake()
    {
        _commands = ServiceLocator.Resolve<ICommandRegistry>();   // registered automatically at startup

        _commands.Register("give", args =>
        {
            if (args.Count < 2) return CommandResult.Fail("usage: give <id> <count>");
            return CommandResult.Ok($"gave {args[1]}x {args[0]}");
        }, new CommandMetadata("Grant an item", "give <id> <count>"));

        CommandResult r = _commands.TryExecute("give sword 3");   // tokenizes, dispatches, records history
        Debug.Log(r.Message);
    }
}

Resolve and cache the service once in Awake(), as shown. Mark the class [DefaultExecutionOrder(100)] so it runs after the framework has started.

API reference

Registration

MemberNotes
void Register(string name, CommandHandler handler, CommandMetadata metadata = default)Map a name to a handler. Registering an existing name follows the duplicate policy you chose at construction: replace with a warning, replace silently, or throw. A null/whitespace name or a null handler throws.
bool Unregister(string name)Remove a name. Returns true if it was present; false for an unknown, null, or whitespace name. Never throws.
bool IsRegistered(string name)Whether a name is registered. Null or whitespace returns false.
bool TryGetMetadata(string name, out CommandMetadata metadata)true plus the entry's metadata, or false plus default.
IReadOnlyList<string> RegisteredNames { get; }A read-only, sorted snapshot of all registered names. The snapshot cannot be modified.

Execution

MemberNotes
CommandResult TryExecute(string rawLine)Tokenize the line, treat the first token as the command name and the rest as arguments, record the line in history, and return the handler's result. Never throws for runtime input.
CommandResult TryExecute(string name, IReadOnlyList<string> args)Dispatch a pre-parsed name and arguments directly. Skips tokenization and does not record history.
IReadOnlyList<string> Tokenize(string rawLine)Single-pass tokenizer: whitespace splitting, double-quote grouping, and backslash escapes. No shell expansion. Never throws.

Autocomplete

MemberNotes
IReadOnlyList<string> Lookup(string prefix)Registered names starting with prefix, sorted. An empty or null prefix returns all names. Never throws.

History (data only — no UI)

MemberNotes
string Previous()Move the cursor one step toward older entries and return that line. Stays put at the oldest entry (no wrap-around); returns null when history is empty.
string Next()Move toward newer entries; returns null past the newest (the blank-input state).
void ResetHistoryCursor()Return the cursor to the newest position.
IReadOnlyList<string> History { get; }A chronological snapshot (oldest to newest); a fresh list each call.

Support types

TypeNotes
delegate CommandResult CommandHandler(IReadOnlyList<string> args)Your handler over the parsed string arguments. The command name is excluded, and the list is never null. The handler parses its own typed values — the registry does no type conversion, which keeps it IL2CPP/AOT-safe.
readonly struct CommandResultbool Success plus string Message (never null). Build one with CommandResult.Ok(msg) or CommandResult.Fail(msg); default counts as a failure.
readonly struct CommandMetadata(string description, string usage)Description and Usage help strings (both never null).
readonly struct CommandRegistryOptionsConstruction policy, via its constructor or .Default: HistoryCapacity (64, clamped to 8–1024), CaseSensitiveNames (true), DuplicateRegistrationPolicy, MaxArgumentCount (0 = unlimited), WarnOnUnknownCommand, WarnOnDuplicateRegistration, WarnOnMalformedLine, InitialCommandCapacity.
enum CommandDuplicatePolicyReplaceWithWarning (default) / ReplaceSilently / Throw.

Build your own console

The registry gives you everything a console needs except the screen: TryExecute produces the output text, Previous/Next drive Up/Down history recall, and Lookup powers autocomplete. Bind those three to any UI you like and you have a working in-game console.

You do not have to start from scratch. The Command Console sample — one of the six samples that ship with the package, and one of the three runnable ones — is a complete, working console built on this service: a dark UGUI panel with an output view, a dim autocomplete hint line, an input field, five demo commands (help, echo, add, timescale, clear), and Up/Down history. Import it via Tools → Common Game System → Welcome → Import "Command Console Sample", open the imported CommandConsole.unity, and press Play. It is the recommended starting point: copy it into your project and extend the command list.

The wiring pattern, reduced to its core:

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

[DefaultExecutionOrder(100)]
public class MyConsole : MonoBehaviour
{
    [SerializeField] private InputField _inputField;  // your input row
    [SerializeField] private Text _outputText;        // your output view

    private ICommandRegistry _commands;
    private bool _fieldFocusedLastFrame;

    private void Awake() => _commands = ServiceLocator.Resolve<ICommandRegistry>();

    private void Update()
    {
        Keyboard kb = Keyboard.current;
        if (kb == null) return;

        // History recall while the field is focused.
        if (_inputField.isFocused)
        {
            if (kb.upArrowKey.wasPressedThisFrame) Recall(_commands.Previous());
            else if (kb.downArrowKey.wasPressedThisFrame) Recall(_commands.Next() ?? string.Empty);
        }

        // Submit on Enter — detected here in Update (see the tip below).
        bool enter = kb.enterKey.wasPressedThisFrame || kb.numpadEnterKey.wasPressedThisFrame;
        if (enter && (_inputField.isFocused || _fieldFocusedLastFrame))
            Submit(_inputField.text);

        _fieldFocusedLastFrame = _inputField.isFocused;
    }

    private void Submit(string line)
    {
        if (string.IsNullOrWhiteSpace(line)) return;
        CommandResult result = _commands.TryExecute(line);   // tokenize + dispatch + history
        _outputText.text += $"\n> {line}\n{result.Message}";
        _commands.ResetHistoryCursor();                      // next Up starts from the newest entry
        _inputField.text = string.Empty;
        _inputField.ActivateInputField();                    // refocus for the next command
    }

    private void Recall(string line)
    {
        if (line == null) return;                            // null only when history is empty
        _inputField.text = line;
        _inputField.caretPosition = line.Length;
        _inputField.ActivateInputField();
    }

    // Autocomplete: call from the field's onValueChanged and render the matches yourself.
    public IReadOnlyList<string> Complete(string prefix) => _commands.Lookup(prefix);
}

Practical tip — detect Enter in Update, not in onEndEdit. It is tempting to submit from the input field's onEndEdit event, but by the time the UI raises onEndEdit, the keyboard's enterKey.wasPressedThisFrame has already gone back to false (measured on Unity 6.3 LTS) — a console that gates its submit on that flag inside onEndEdit will silently swallow every submit. Poll the keyboard in Update instead, as the snippet above does. One more subtlety: the same Enter press can deactivate the input field earlier in the frame, so accept the press if the field is focused now or was focused on the previous frame — that is what _fieldFocusedLastFrame is for.

Two more habits from the sample worth copying:

  • Register on the shared registry, and clean up only your own names. In OnDestroy, call Unregister for exactly the commands you added. Never Dispose() the registry — it is a shared service owned by Bootstrap.
  • If typing or Enter gets no response at all, your project is likely still on Unity's old input backend. Open Tools → Common Game System → Welcome and click Enable the new Input System (one editor restart), then Play again.

Examples

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class DebugConsoleInput : MonoBehaviour
{
    private ICommandRegistry _commands;

    private void Awake()
    {
        _commands = ServiceLocator.Resolve<ICommandRegistry>();
        _commands.Register("god", _ => CommandResult.Ok("god mode toggled"),
            new CommandMetadata("Toggle invulnerability", "god"));
    }

    // Called by your own UI when the player submits a console line.
    public string Submit(string line)
    {
        CommandResult r = _commands.TryExecute(line);   // recorded in history automatically
        return r.Message;                               // your UI prints it
    }

    // Up/Down arrow recall — pure data; you bind the keys.
    public string RecallPrevious() => _commands.Previous();
    public string RecallNext() => _commands.Next();

    // Tab autocomplete — you render the candidate list.
    public IReadOnlyList<string> Complete(string prefix) => _commands.Lookup(prefix);
}

Turning it off

ServiceLocator.Replace<ICommandRegistry>(new NullCommandRegistry());

This mutes all commands. Register/Unregister do nothing, TryExecute returns a failed CommandResult, and every query returns an empty collection or null. A game wired against a real registry keeps running with its cheats and commands silently inactive. One warning is logged when the replacement is constructed, so the swap is never silent. Programming errors still throw — a null/whitespace name, a null handler, use after disposal — the same rule every null implementation in the framework follows.

Common pitfalls

  • The console UI is yours. The registry ships the data core only — there is no print, render, or key-toggle API. Bind your own UI to TryExecute (output), Previous/Next (history recall), and Lookup (autocomplete). CommandResult.Message is the only text the registry produces.
  • Handlers parse their own arguments. Arguments arrive as IReadOnlyList<string>, with the command name excluded. The registry does no typed conversion or reflection binding — call int.Parse and friends yourself. This is what keeps it IL2CPP/AOT-safe.
  • The two TryExecute overloads treat history differently. The string rawLine overload tokenizes and records history; the pre-parsed (name, args) overload skips both. Use the raw-line form for console input and the parsed form for programmatic dispatch.
  • default(CommandRegistryOptions) is not .Default. A bare new CommandRegistryOptions() (or default) is the all-zeros bag: history capacity 0, case-insensitive names. The registry recovers sensible values from the zeros, but pass CommandRegistryOptions.Default explicitly to get the canonical policy.
  • Main thread only. Like most framework services, the registry asserts the main thread. Calling it from a worker thread is a programming error.
  • Logger — the diagnostics sink the registry logs through
  • Service Locator — resolving the service and swapping in the null implementation
  • Bootstrap — how every service is registered and shut down automatically
  • Time — what the sample's timescale command drives