Writing plugins

A MultiShock plugin is a .NET Razor class library that MultiShock loads at startup. Plugins can add pages to the sidebar, a settings screen, and flow nodes, and can send commands to the user's shockers. This guide assumes you know C# and Blazor, and covers version 1.15.0 of the plugin SDK in this source tree. Build against an SDK supported by your target MultiShock release; installing a newer package in your plugin does not upgrade the host.

For more detail, see lifecycle and host services, pages and shared UI, developing flow nodes, and packaging and testing.

Quick start

The easiest way to start is from the official plugins repository, bwmp/Multishock.Plugins:

  1. Fork the repository and clone it.
  2. Run .\scripts\new-plugin.ps1. It asks for a name, ID, and description, and creates a project with a plugin class, a page, a settings screen, and an example flow node.
  3. Build the project. It copies the plugin into MultiShock's plugin folder.
  4. Restart MultiShock, then switch on your plugin's page under Manage plugins → Configure → Sidebar pages.

Check the generated project's SDK package reference and manifest before building: the template may pin an older SDK. Use a dedicated development install directory and remove duplicate copies with the same plugin ID.

The rest of this page explains the pieces, whether you use the template or start from scratch.

Project setup

Create a Razor class library that targets .NET 10 and references the SDK package, PiShock.MultiShock.PluginSdk:

MyPlugin.csproj
<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components" Version="9.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.0" />
    <PackageReference Include="PiShock.MultiShock.PluginSdk" Version="1.15.0" />
  </ItemGroup>
</Project>

MultiShock always uses its own copy of the SDK, so don't ship MultiShock.PluginSdk.dll with your plugin.

Install the .NET 10 SDK to build this project. An external Razor library does not require the MAUI workload. PiShock.MultiShock.PluginSdk is the package ID; MultiShock.PluginSdk is the namespace and assembly name.

For a project created by hand, put the project file, plugin class, and node class below in one MyPlugin directory. Add the Razor page when you want a UI, and add these shared component imports:

_Imports.razor
@using Microsoft.AspNetCore.Components
@using MultiShock.PluginSdk
@using MultiShock.PluginSdk.Components

From that directory, build with:

dotnet build .\MyPlugin.csproj -c Release

This produces bin\Release\net10.0\MyPlugin.dll. A project without a custom copy target does not install itself; follow local installation after building.

The manifest

Put a plugin.json file next to your DLL. It tells MultiShock what the plugin is and whether it can run:

plugin.json
{
  "manifestVersion": 2,
  "id": "com.example.myplugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "entryPoint": "MyPlugin.dll",
  "description": "Does something useful.",
  "sdkVersion": "1.15.0",
  "platforms": ["win-x64"]
}
FieldRequiredMeaning
manifestVersionyesAlways 2.
idyesA unique ID in lowercase, such as com.example.myplugin.
nameyesThe name users see.
versionyesThe plugin's version, such as 1.0.0.
entryPointyesThe DLL that contains your plugin class.
descriptionA one-line description.
authorsA list of author names.
sourceUrlWhere to find the source or downloads.
minAppVersionThe oldest MultiShock version it runs on, such as 4.8.0.
sdkVersionThe SDK version you built against.
dependenciesOther plugins it needs, as { "id", "range", "optional" }.
tagsWords to help people find it.
platformsRuntime identifiers it runs on, such as win-x64. Leave it out to run anywhere.

MultiShock skips a plugin when the app is older than minAppVersion, when sdkVersion has a newer major version than MultiShock's SDK, or when the current platform isn't listed. Keep id and version the same as your plugin class's Id and Version; update checks compare against those.

The SDK check only compares major versions. It does not guarantee that an older host has APIs introduced in a newer SDK minor release. Test the oldest app you support and set minAppVersion accordingly. See the manifest guide for dependency limits.

The plugin class

Every plugin has one public class that implements IPlugin and has a parameterless constructor:

MyPlugin.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MultiShock.PluginSdk;
using MultiShock.PluginSdk.Flow;
 
namespace MyPlugin;
 
public class MyPlugin : IPlugin, IPluginRouteProvider, IFlowNodeProvider
{
    public const string PluginId = "com.example.myplugin";
 
    public string Id => PluginId;
    public string Name => "My Plugin";
    public string Version => "1.0.0";
    public string Description => "Does something useful.";
 
    internal static ILogger? Logger { get; private set; }
 
    public void ConfigureServices(IServiceCollection services)
    {
        // Register your own services here.
    }
 
    public void Initialize(IServiceProvider services)
    {
        var host = services.GetRequiredService<IPluginHost>();
        Logger = host.CreateLogger("MyPlugin.Plugin");
        Logger.LogInformation("My Plugin started");
    }
 
    public IEnumerable<NavigationItem> GetNavigationItems() =>
    [
        new NavigationItem { Text = "My Plugin", Href = "/myplugin", Icon = "puzzle" },
    ];
 
    public IEnumerable<IFlowNode> GetNodeTypes() => [new BuzzNode()];
}

MultiShock calls the methods in this order:

  1. ConfigureServices, while the app is starting, to add your services to the app's service container.
  2. Initialize, once the app has started, with the service provider. Start background work here.
  3. GetNodeTypes, once, when the flow system loads.

There is no shutdown method; when MultiShock closes, your plugin closes with it.

Services you can use

Resolve these from the service provider in Initialize, from @inject in your pages, or from context.GetService<T>() in flow nodes:

  • IPluginHost:
    • CreateLogger(category) writes to MultiShock's logs. Include "Plugin" in the category name, as above, so the messages go to the plugin log file.
    • ShowSuccessToast, ShowWarningToast, ShowInfoToast, and ShowErrorToast show notifications.
    • GetPluginDataPath(pluginId) returns a folder for your plugin's files. Save your settings there: GetSetting and SetSetting only keep values until MultiShock closes.
    • GetPlugin<T>() and GetPluginService<T>() reach other plugins.
  • IDeviceActions:
    • GetLoadedShockers() lists the shockers in use.
    • PerformAction(intensity, durationSeconds, CommandType.Vibrate, ...) sends a command. It targets every loaded shocker unless you pass deviceIds or shockerIds, and shockerOption: "random" picks one of them.

Commands sent with PerformAction go through the user's safety controls: Allow shocks, Power limit, cooldowns, and Stop everything. IDeviceActions also has raw serial methods for USB hubs; those bypass the safety controls, so use PerformAction for anything that reaches a person.

Pages

Add pages as Razor components. Their route must start with /plugins/, then your plugin ID with dots replaced by dashes:

HomePage.razor
@page "/plugins/com-example-myplugin/myplugin"
 
<h1 class="page-title">My Plugin</h1>

To list a page in the sidebar, implement IPluginRouteProvider as in the plugin class above. Href is the part after your plugin's prefix, and Icon is the name of a Lucide icon. Users have to switch new pages to Shown under Manage plugins → Configure → Sidebar pages before they appear, so mention that in your plugin's instructions.

Styling

Pages can use the host's bundled Tailwind utilities and MultiShock's theme:

  • Colors such as bg-app-surface, text-app-muted, border-app-border, and text-app-primary, which follow the user's theme.
  • CSS variables for your own styles, such as var(--ms-surface), var(--ms-text), and var(--ms-primary).
  • Components from MultiShock.PluginSdk.Components, including PageHeader, Card, Button, Toggle, TextInput, Select, Slider, Alert, and ShockerSelect.

For CSS of your own, implement IPluginWithStyles and return it from GetStylesheet().

Runtime plugin source is not scanned by the host's Tailwind build. Arbitrary values and custom classes need CSS you supply. See styling and shared components.

Settings screen

Implement IConfigurablePlugin to add a settings screen under Manage plugins → Configure. GetConfigurationComponentType() returns your Razor component, which receives a PluginId parameter. MultiShock doesn't currently call OnConfigurationChanged, so have your component save settings itself, to a file under GetPluginDataPath.

Flow nodes

Implement IFlowNodeProvider and return one instance of each node type from GetNodeTypes(). This node vibrates every loaded shocker:

BuzzNode.cs
using MultiShock.PluginSdk;
using MultiShock.PluginSdk.Flow;
 
namespace MyPlugin;
 
public sealed class BuzzNode : IFlowProcessNode
{
    public string TypeId => "myplugin.buzz";
    public string DisplayName => "Buzz";
    public string Category => FlowNodeCategory.Action;
    public string? Description => "Vibrates every loaded shocker for one second.";
    public string Icon => "vibrate";
    public string? Color => null;
 
    public IReadOnlyList<FlowPort> InputPorts { get; } =
    [
        FlowPort.FlowIn(),
        FlowPort.Number("intensity", "Intensity"),
    ];
 
    public IReadOnlyList<FlowPort> OutputPorts { get; } = [FlowPort.FlowOut()];
 
    public IReadOnlyDictionary<string, FlowProperty> Properties { get; } =
        new Dictionary<string, FlowProperty>
        {
            ["intensity"] = FlowProperty.Int("Intensity", 20, min: 1, max: 100),
        };
 
    public Task<FlowNodeResult> ExecuteAsync(
        IFlowNodeInstance instance,
        FlowExecutionContext context,
        CancellationToken cancellationToken)
    {
        var actions = context.GetService<IDeviceActions>();
        if (actions is null)
            return Task.FromResult(FlowNodeResult.Fail("Device actions not available"));
 
        // Unconnected inputs are missing from Inputs, so fall back to the property.
        var intensity = context.Inputs.ContainsKey("intensity")
            ? context.GetInput<int>("intensity")
            : instance.GetConfig("intensity", 20);
 
        actions.PerformAction(Math.Clamp(intensity, 1, 100), 1.0, CommandType.Vibrate);
        return Task.FromResult(FlowNodeResult.ActivatePort("done"));
    }
 
    public IFlowNodeInstance CreateInstance(string instanceId, Dictionary<string, object?> config)
        => new FlowNodeInstance(instanceId, this, config);
}

Ports and properties

  • FlowPort.FlowIn() and FlowPort.FlowOut() create the usual Execute and Done pins. FlowPort.Boolean, Number, String, Any, Array, and Object create data pins. See pin types for what can connect to what.
  • FlowProperty.String, Int, Double, Bool, and Select create settings in the property panel. Int and Double show a slider when you give both a minimum and a maximum. Set Required = true to have the editor flag a missing value.
  • New nodes start with no saved settings, so GetConfig returns the property's default. That only works when the default's type matches the type you ask for, so read an Int property with GetConfig<int>, and so on.
  • context.Inputs only holds connected inputs. Fall back to a property when an input isn't connected, as above.

Results

Return one of these from ExecuteAsync:

ResultWhat happens
FlowNodeResult.ActivatePort("done", outputs)Sets the data outputs and continues from that pin. Use it to pick a branch.
FlowNodeResult.ActivatePorts([...], outputs)Continues from several pins, in order.
FlowNodeResult.Success(outputs)Sets the data outputs and continues from Done.
FlowNodeResult.Stop()Ends this branch quietly.
FlowNodeResult.Fail("message")Ends this branch and shows the red error badge on the node, with the message.

Add .WithDisplayValue("text") to any result to show a value under the node's pins.

Trigger nodes

A trigger implements IFlowTriggerNode instead:

  • StartAsync(instance, services, cancellationToken) runs when a flow using the node starts. Begin listening for your event here.
  • StopAsync(instance) runs when that flow stops.
  • Raise the Triggered event with the same instance and a dictionary of output values to start the flow.

A trigger's Flow output must have the ID triggered, for example new FlowPort { Id = "triggered", Name = "Triggered", Type = FlowPortType.Flow }. MultiShock uses one node object for every flow, so keep per-flow state keyed by instance, or in instance.State.

Naming

  • MultiShock stores your node types as yourPluginId:typeId, so a short TypeId such as myplugin.buzz is enough.
  • Use a FlowNodeCategory constant (Trigger, Logic, Action, and so on) to put your node in a standard group and give it a matching header color. Any other category name gets its own group, listed after the standard ones.

Build and test

The template's project copies your DLL to %LocalAppData%\PiShock\MultiShock\Plugins after each build. Without it, copy the DLL there yourself, then restart MultiShock to load it.

To reload your plugin without restarting, use development mode:

  1. Create an empty file next to your DLL with .dev added to its name, such as MyPlugin.dll.dev.
  2. Restart MultiShock once.
  3. After each build, open Manage plugins → Developer and click Reload on your plugin.

Reloading picks up changes to pages and services. Changes to flow nodes still need a restart.

If your plugin doesn't load, open Settings → Advanced → Debug & diagnostics → Plugins and check Recent Plugin Errors. Your plugin's log messages are in the plugin log, next to the main log. See troubleshooting.

Share your plugin

A .msplugin file is a zip archive with plugin.json at the top, next to your DLL and its dependencies. Users install it by copying it into their plugin folder; see installing plugins.

To get a plugin into the Browse list, open a pull request to bwmp/Multishock.Plugins. Plugins there are built, packaged, and published to the catalog automatically when a new version is released.

Contributors

Thank you to everyone who has helped improve this documentation.