A Discord bot for tabletop RPG character generation with built-in support for MÖRK BORG and a modular architecture for adding game systems. Handles character creation, multi-character generation, and PDF export.
- Slash command framework —
/generatecommand with game-module routing; each game module owns its command definitions, option parsing, and output behavior - DM delivery — Characters sent via DM; in-channel confirmation keeps channels clean
- Guild-scoped commands — Command registration can be targeted to specific guilds for near-instant propagation (~15 seconds); omit guild IDs for global registration (~1 hour)
- Ephemeral responses — Private follow-ups visible only to the requesting user
- Full implementation of the MÖRK BORG ruleset:
- Ability score rolling (3d6 standard; 4d6 drop-lowest heroic mode for classless characters)
- Character class assignment (6 official classes + classless)
- Equipment, inventory, and starting container generation
- Vignette (backstory) generation
- Omens, scrolls, and HP determination
- PDF export — Generates a filled PDF character sheet using an official-layout template
- Multi-character generation — Generate up to 4 characters in a single command, delivered as a downloadable ZIP file
- 200+ reference data entries — Armour, weapons, spells, items, names, descriptions, and vignettes in versioned JSON files
- .NET 10 with nullable reference types enabled throughout
- Six-project solution —
ScvmBot.Bot(Discord host),ScvmBot.Cli(CLI host),ScvmBot.Modules(shared contracts),ScvmBot.Modules.MorkBorg(module adapter),ScvmBot.Games.MorkBorg(game logic),ScvmBot.Games.MorkBorg.Pdf(PDF rendering) - Modular game system architecture — Game modules implement
IModuleRegistrationin an assembly namedScvmBot.Modules.*and are discovered automatically at startup via the dependency graph. Adding a game means adding projects and a project reference from the host — no configuration files or plugin manifests - 459 tests across four test projects covering generation logic, equipment flow, PDF mapping, option parsing, command handling, multi-character generation, and architectural constraints
- Fail-fast module initialization — Each
IModuleRegistrationloads required data duringInitializeAsync(); missing files abort startup with a non-zero exit code - Testable command layer —
ISlashCommandContextinterface decouples command handlers from sealed Discord.Net types, enabling full unit test coverage without Discord infrastructure - CLI host —
scvmbot-cliprovides local character generation, file rendering, and benchmarking through the same module pipeline, with no Discord dependency - Docker ready —
Dockerfileanddocker-compose.ymlincluded
- .NET 10 SDK
- A Discord bot token — create one at the Discord Developer Portal
-
Clone the repository
git clone https://github.com/ChrisMartin86/ScvmBot.git cd ScvmBot -
Configure the bot
ScvmBot uses .NET's standard configuration pipeline. Every setting can come from
appsettings.json, environment variables, or command-line arguments. Environment variables take precedence over file values.For local development, copy and edit the example settings file:
cp src/ScvmBot.Bot/appsettings.example.json src/ScvmBot.Bot/appsettings.json
-
Run the bot
dotnet run --project src/ScvmBot.Bot
See the Getting Started guide for the full configuration reference, including every available setting and its environment variable equivalent.
The Dockerfile produces a standard .NET application image. Provide configuration however your environment supports it — environment variables, mounted config files, orchestrator secrets, etc.
A docker-compose.yml is included as an example. To use it from the repository root:
export DISCORD_TOKEN=your_token_here
docker compose up --buildThe compose file reads an optional .env file for additional settings:
DISCORD_TOKEN=your_token_here
BOT_SYNC_COMMANDS=true
Discord__GuildIds__0=123456789012345678
The compose file maps convenience shell variables (like DISCORD_TOKEN) to the app's actual configuration keys (like Discord__Token). See docker-compose.yml for the full mapping.
| Command | Description |
|---|---|
/generate morkborg character |
Generate a single MÖRK BORG character |
/generate morkborg character class:<name> |
Generate with a specific class (or None for classless) |
/generate morkborg character roll-method:4d6-drop-lowest |
Use heroic ability rolling (classless only) |
/generate morkborg character count:<1-4> |
Generate multiple characters in one command |
/hello |
Verify bot is online |
Characters are delivered via DM. In-channel replies confirm delivery.
Adding a game system requires two projects, one required interface, and two optional ones. The MÖRK BORG module is the reference implementation — every pattern below has a working example in the codebase.
| Concern | Required | Interface / Type | Example |
|---|---|---|---|
| Module registration | Yes | IModuleRegistration |
MorkBorgModuleRegistration |
| Command handling | Yes | IGameModule |
MorkBorgModule |
| Card rendering | Yes | IResultRenderer (Card) |
MorkBorgCharacterEmbedRenderer |
| File rendering | No | IResultRenderer (File) |
MorkBorgCharacterPdfRenderer |
Create two projects under src/:
ScvmBot.Games.YourGame/— Game logic, models, data loading. No dependency onScvmBot.Modules. This project is pure game code.ScvmBot.Modules.YourGame/— Module adapter that bridges your game logic to the bot framework. References bothScvmBot.ModulesandScvmBot.Games.YourGame.
The ScvmBot.Modules. prefix is required — ModuleBootstrapper scans the dependency graph for assemblies matching this prefix and ignores everything else.
Add a project reference from ScvmBot.Bot (and ScvmBot.Cli if desired) to ScvmBot.Modules.YourGame. This is the only wiring step — no registration files, no manifest.
This is the entry point the host calls at startup. It must have a public parameterless constructor.
public sealed class YourGameModuleRegistration : IModuleRegistration
{
public async Task<Action<IServiceCollection>> InitializeAsync(IConfiguration configuration)
{
// Navigate to your config section. Convention: Modules:{ModuleName}
var dataPath = configuration["Modules:YourGame:DataPath"]
?? configuration["Modules:DataPath"];
// Load reference data. Throw on failure — the host treats exceptions as
// fatal startup errors and exits with a non-zero code.
var data = await YourDataService.LoadAsync(dataPath);
// Return a registration callback. The host calls this once to populate DI.
return services =>
{
services.AddSingleton(data);
services.AddSingleton<IGameModule, YourGameModule>();
services.AddSingleton<IResultRenderer, YourCardRenderer>();
// Optional: file renderer for PDF/image export
// services.AddSingleton<IResultRenderer, YourPdfRenderer>();
};
}
}Key rules:
- The async portion (
InitializeAsync) runs before DI is built. Load data, validate files, and fail fast here. - The returned
Action<IServiceCollection>registers everything the module needs. - Register exactly one
IGameModuleimplementation. Register one or moreIResultRendererimplementations.
This is the runtime contract the bot uses to build commands and dispatch generation requests.
public sealed class YourGameModule : IGameModule
{
private readonly YourCharacterGenerator _generator;
public YourGameModule(YourCharacterGenerator generator, YourDataService data)
{
_generator = generator;
// Build subcommand definitions. These become /generate yourgame <subcommand>.
SubCommands = YourCommandDefinition.BuildSubCommands(data);
}
public string Name => "Your Game"; // Display name shown in the command description
public string CommandKey => "yourgame"; // Becomes /generate yourgame — must be unique
public IReadOnlyList<SubCommandDefinition> SubCommands { get; }
public Task<GenerateResult> HandleGenerateCommandAsync(
string subCommand,
IReadOnlyDictionary<string, object?> options,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (!string.Equals(subCommand, "character", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Unknown subcommand '{subCommand}'.");
var character = _generator.Generate(options);
return Task.FromResult<GenerateResult>(
new GenerationBatch<YourCharacter>(new[] { character }));
}
}Define your command structure using the transport-agnostic record types. These are mapped to Discord slash command options by DiscordCommandAdapter and to CLI arguments by the CLI host — your module never touches Discord types.
public static class YourCommandDefinition
{
public static IReadOnlyList<SubCommandDefinition> BuildSubCommands(YourDataService data)
{
return new[]
{
new SubCommandDefinition("character", "Generate a random character", new CommandOptionDefinition[]
{
new("difficulty",
"Starting difficulty level",
CommandOptionType.String, Required: false,
Choices: new[] { new CommandChoice("Easy", "easy"), new CommandChoice("Hard", "hard") }),
new("count",
"Number of characters to generate",
CommandOptionType.Integer, Required: false, MinValue: 1,
Role: CommandOptionRole.GenerationCount)
})
};
}
}CommandOptionRole.GenerationCount tells transport hosts this option controls batch size. The Discord host uses it to cap the value at its transport-specific maximum (currently 4) so the Discord UI enforces the limit before the command reaches the handler.
All generation methods must return a GenerationBatch<TCharacter> where TCharacter is your game-specific model type. The batch must contain at least one character — the constructor throws ArgumentException on empty lists.
For multi-character support, parse the count option and generate multiple characters:
var count = ParseCount(options); // your parser; default to 1 if missing
var characters = Enumerable.Range(0, count).Select(_ => _generator.Generate()).ToList();
var groupName = count > 1 ? GenerateGroupName(characters) : null;
return new GenerationBatch<YourCharacter>(characters.AsReadOnly(), groupName);Renderers convert a GenerateResult into output the host can deliver. Each renderer declares what result type and output format it handles.
Every module must register at least one card renderer. The card output is what appears as a Discord embed or CLI text output.
public sealed class YourCardRenderer : IResultRenderer
{
public Type ResultType => typeof(GenerationBatch<YourCharacter>);
public OutputFormat Format => OutputFormat.Card;
public bool CanRender(GenerateResult result) =>
result is GenerationBatch<YourCharacter>;
public RenderOutput Render(GenerateResult result)
{
if (result is not GenerationBatch<YourCharacter> batch)
throw new InvalidOperationException($"Cannot render {result.GetType().Name}.");
var character = batch.Characters[0];
return new CardOutput(
Title: character.Name,
Description: $"Level {character.Level} — HP {character.HitPoints}",
Color: new CardColor(100, 150, 200),
Fields: new[]
{
new CardField("Abilities", FormatAbilities(character)),
new CardField("Equipment", FormatEquipment(character))
});
}
}For multi-character results, check batch.Characters.Count and return either a detailed single-character card or a roster summary — see MorkBorgCharacterEmbedRenderer.BuildRosterCard for the pattern.
If your game has PDF export, image generation, or any downloadable file output, add a file renderer. When a file renderer is registered, the bot automatically attaches the file alongside the card embed.
public sealed class YourPdfRenderer : IResultRenderer
{
public Type ResultType => typeof(GenerationBatch<YourCharacter>);
public OutputFormat Format => OutputFormat.File;
public bool CanRender(GenerateResult result) =>
result is GenerationBatch<YourCharacter> && PdfTemplateExists();
public RenderOutput Render(GenerateResult result)
{
if (result is not GenerationBatch<YourCharacter> batch)
throw new InvalidOperationException($"Cannot render {result.GetType().Name}.");
if (batch.Characters.Count == 1)
{
var pdf = RenderPdf(batch.Characters[0]);
return new FileOutput(pdf, $"{batch.Characters[0].Name}.pdf");
}
// Multiple characters: render each as a PDF, bundle into a ZIP
var memberPdfs = batch.Characters
.Select(c => (c.Name, PdfBytes: RenderPdf(c)))
.ToList();
var zipBytes = CharacterZipBuilder.CreateZip(memberPdfs);
var zipName = CharacterZipBuilder.GenerateZipFileName(
batch.GroupName ?? "characters");
return new FileOutput(zipBytes, zipName);
}
}CharacterZipBuilder handles ZIP creation and filename sanitization — use it instead of rolling your own.
- Each
(ResultType, OutputFormat)pair must have exactly one renderer.RendererRegistryvalidates this at startup. CanRenderis a runtime guard — returnfalseif a required resource is unavailable (e.g. missing PDF template) and the host will skip file rendering gracefully.
Create a test project tests/ScvmBot.Games.YourGame.Tests/ for game logic tests and optionally tests/ScvmBot.Modules.YourGame.Tests/ for module adapter tests. The shared test infrastructure in ScvmBot.Tests.Shared provides helpers like SharedTestInfrastructure.GetRepositoryRoot() for locating data files.
Test the module through the same pipeline the hosts use:
[Fact]
public async Task Generate_ReturnsCharacterWithRequiredFields()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Modules:YourGame:DataPath"] = "/path/to/data"
})
.Build();
var register = await new YourGameModuleRegistration().InitializeAsync(config);
var services = new ServiceCollection();
register(services);
services.AddSingleton<RendererRegistry>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
var provider = services.BuildServiceProvider();
var module = provider.GetRequiredService<IGameModule>();
var result = await module.HandleGenerateCommandAsync("character", new Dictionary<string, object?>());
var batch = Assert.IsType<GenerationBatch<YourCharacter>>(result);
Assert.False(string.IsNullOrWhiteSpace(batch.Characters[0].Name));
}Host (Bot or CLI)
│
├── ModuleBootstrapper.DiscoverAndInitializeAsync()
│ └── Scans for ScvmBot.Modules.* assemblies
│ └── Calls IModuleRegistration.InitializeAsync() on each
│ └── Returns DI registration callbacks
│
├── DI Container
│ ├── IGameModule instances (one per game system)
│ ├── IResultRenderer instances (card + optional file per module)
│ └── RendererRegistry (selects renderer by result type + format)
│
└── /generate command
├── Routes to IGameModule by CommandKey
├── Module returns GenerationBatch<T>
├── RendererRegistry.RenderCard() → CardOutput → Discord embed
└── RendererRegistry.TryRenderFile() → FileOutput? → attachment
dotnet testIndividual test projects:
dotnet test tests/ScvmBot.Bot.Tests
dotnet test tests/ScvmBot.Games.MorkBorg.Tests
dotnet test tests/ScvmBot.Games.MorkBorg.Pdf.Tests
dotnet test tests/ScvmBot.Cli.Tests| Package | Version | Purpose |
|---|---|---|
| Discord.Net | 3.19.1 | Discord API |
| iText7 | 9.5.0 | PDF form filling |
| itext7.bouncy-castle-adapter | 9.5.0 | iText7 cryptography runtime requirement |
| Newtonsoft.Json | 13.0.4 | Version pin — prevents vulnerable transitive version via Discord.Net |
| Microsoft.Extensions.Hosting | 10.0.5 | DI / hosted service |
| Microsoft.Extensions.Logging | 10.0.5 | Structured logging |
Original artwork for ScvmBot is by Thomas Kolvenbag.
This artwork is not covered by the MIT licence and is not included in the open-source distribution. All rights to the artwork remain with the artist.
MIT © 2025 Christopher Martin
ScvmBot is an independent production by Christopher Martin and is not affiliated with Ockult Örtmästare Games or Stockholm Kartell. It is published under the MÖRK BORG Third Party License.
MÖRK BORG is © 2019 Ockult Örtmästare Games and Stockholm Kartell.
See THIRD_PARTY_LICENSES.md for all third-party licence details.