Skip to main content

The project file

A plugin is an ordinary SDK-style class library with a few settings that make it loadable as a plugin. This page explains each line of a plugin's project file: dynamic loading, references to the engine that are used to compile but not copied, NuGet packages, the second project for the editor part, and the build step that installs the plugin into a game.

A complete project file​

This is the runtime project of the Spinners plugin from Your first plugin, written by hand. TalesmithDir is the folder of your editor build, the folder that contains talesmith.dll:

plugins-src/Spinners/Spinners.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<ImportDirectoryBuildProps>false</ImportDirectoryBuildProps>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<TalesmithDir>/home/you/Talesmith/src/Talesmith.App/bin/Debug/net10.0/</TalesmithDir>
<PluginDestination>$(MSBuildThisFileDirectory)../../assets/plugins/spinners/</PluginDestination>
</PropertyGroup>
<ItemGroup>
<Reference Include="$(TalesmithDir)Talesmith.Core.dll;$(TalesmithDir)Talesmith.Runtime.dll;$(TalesmithDir)Talesmith.Plugins.dll" Private="false" />
<Reference Include="$(TalesmithDir)Talesmith.Assets.dll;$(TalesmithDir)Talesmith.Rendering.dll" Private="false" />
<Reference Include="$(TalesmithDir)Microsoft.Extensions.DependencyInjection.Abstractions.dll" Private="false" />
</ItemGroup>
<ItemGroup>
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="InstallPlugin" AfterTargets="Build">
<PropertyGroup>
<HostSharedPattern>^(Talesmith|Microsoft\.Extensions|Avalonia|SkiaSharp|CommunityToolkit|System|mscorlib|netstandard|HarfBuzzSharp|Silk\.NET)(\.|$)</HostSharedPattern>
</PropertyGroup>
<ItemGroup>
<PluginFiles Include="$(TargetDir)$(TargetName).dll;$(TargetDir)$(TargetName).pdb;$(TargetDir)$(TargetName).deps.json;$(TargetDir)plugin.json" />
<PluginDependencies Include="@(ReferenceCopyLocalPaths)" Condition="!$([System.Text.RegularExpressions.Regex]::IsMatch('%(ReferenceCopyLocalPaths.Filename)', '$(HostSharedPattern)')) And !$([System.Text.RegularExpressions.Regex]::IsMatch('%(ReferenceCopyLocalPaths.NuGetPackageId)', '$(HostSharedPattern)'))" />
</ItemGroup>
<Copy SourceFiles="@(PluginFiles)" DestinationFolder="$(PluginDestination)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginDependencies)" DestinationFiles="@(PluginDependencies->'$(PluginDestination)%(DestinationSubDirectory)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
LineWhy
TargetFramework net10.0The engine runs on .NET 10. A plugin cannot target a newer framework than the host.
EnableDynamicLoadingMarks the library as a component that is loaded at run time. The build writes a .deps.json describing its dependencies, which the engine uses to find them, and copies NuGet dependencies to the output folder.
ImportDirectoryBuildProps, ManagePackageVersionsCentrallyKeep the project independent of a Directory.Build.props or central package versions in a folder above it, such as a repository the game lives in.
Reference … Private="false"Compile against the engine without copying it. See the next section.
None Include="plugin.json"Copies the manifest into the build output, so the install step finds it.
InstallPluginAfter every build, copies the plugin's files and its private dependencies into the game. HostSharedPattern leaves out the assemblies the editor and games share with plugins and the native libraries the engine ships.

The assembly name is the project name, so Spinners.csproj builds Spinners.dll, which is what assembly in plugin.json names. Do not name a plugin assembly Talesmith or Talesmith.Something: the engine shares assemblies with those names with the host instead of loading them from the plugin's folder.

Engine references​

The engine is already loaded when a plugin is, so a plugin must not bring its own copy. Two things make sure of that:

  • Private="false" keeps the referenced assembly out of the build output.
  • The engine shares its assemblies with every plugin. Assemblies named Talesmith, Microsoft.Extensions, Avalonia, SkiaSharp, CommunityToolkit and System, or starting with one of those names and a dot, always come from the host. That keeps one IPlugin, one World and one IServiceCollection type for everyone.

Reference what your code uses. The runtime assemblies are:

AssemblyContains
Talesmith.CoreECS (World, queries), systems, IPlugin, authoring attributes, time, events, math
Talesmith.RuntimeThe game host, scenes, built-in components such as Transform and Camera, serialization and value converters
Talesmith.PluginsPluginLoader, PluginManager, PluginSettings, PluginPermissionNames
Talesmith.AssetsThe asset manager, importers, asset kinds, localization
Talesmith.Rendering, Talesmith.GridsCameras, colors and render types; hex and square grids
Talesmith.Input, Talesmith.AudioInput and audio services
Talesmith.Physics, Talesmith.Lighting, Talesmith.VFXPhysics, lights and particles
Talesmith.ScriptingScript, for plugins that ship scripts
Talesmith.AvaloniaIGameOverlay; add Avalonia.Base.dll and Avalonia.Controls.dll to write overlays
Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Logging.AbstractionsIServiceCollection, AddSingleton, ILogger

The editor's New plugin project… button writes one Reference with a HintPath for every runtime assembly in the editor's folder, which is the same thing in a longer form. It does not add Avalonia; add the two Avalonia references above before you write an overlay.

Logging source generator

[LoggerMessage] methods need the logging source generator, which comes with the Microsoft.Extensions.Logging.Abstractions NuGet package, not with the bare assembly. Either call logger.LogInformation(…) and the other extension methods, or reference the package with <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" ExcludeAssets="runtime" />.

Referencing the engine's projects​

If the plugin lives in a clone of the Talesmith repository, reference the engine's projects instead of its build output, as the samples do. ExcludeAssets="runtime" keeps the engine's own dependencies out of the plugin's .deps.json as well:

<ItemGroup>
<ProjectReference Include="..\..\src\Talesmith.Runtime\Talesmith.Runtime.csproj" Private="false" ExcludeAssets="runtime" />
<ProjectReference Include="..\..\src\Talesmith.Avalonia\Talesmith.Avalonia.csproj" Private="false" ExcludeAssets="runtime" />
</ItemGroup>

A project reference copies the engine's other assemblies into the plugin's bin folder anyway. That does no harm: they are not private dependencies, so the install step leaves them out.

NuGet packages​

Packages that are not part of the engine are private to the plugin. Add them as usual:

<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
</ItemGroup>

The engine loads each plugin into its own load context and resolves the plugin's assemblies through its .deps.json from the plugin's folder. Two plugins can therefore use different versions of the same package. The package's files must be in the plugin's folder, and the install step above copies them there; see Packaging.

Packages whose names start with one of the shared names (Avalonia, SkiaSharp, CommunityToolkit, Microsoft.Extensions) always resolve to the host's copy. Reference them with ExcludeAssets="runtime" and the version the engine uses, which you can read in the repository's Directory.Packages.props.

The editor assembly​

Code that extends the editor goes into a second project, which builds the assembly named by editorAssembly in plugin.json. It references the editor and the plugin's runtime project:

plugins-src/Spinners.Editor/Spinners.Editor.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<ImportDirectoryBuildProps>false</ImportDirectoryBuildProps>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<TalesmithDir>/home/you/Talesmith/src/Talesmith.App/bin/Debug/net10.0/</TalesmithDir>
<PluginDestination>$(MSBuildThisFileDirectory)../../assets/plugins/spinners/</PluginDestination>
</PropertyGroup>
<ItemGroup>
<Reference Include="$(TalesmithDir)Talesmith.*.dll" Private="false" />
<Reference Include="$(TalesmithDir)Avalonia*.dll" Private="false" />
<Reference Include="$(TalesmithDir)CommunityToolkit.Mvvm.dll" Private="false" />
<Reference Include="$(TalesmithDir)Microsoft.Extensions.DependencyInjection.Abstractions.dll" Private="false" />
<ProjectReference Include="../Spinners/Spinners.csproj" Private="false" />
</ItemGroup>
<Target Name="InstallPlugin" AfterTargets="Build">
<PropertyGroup>
<HostSharedPattern>^(Talesmith|Microsoft\.Extensions|Avalonia|SkiaSharp|CommunityToolkit|System|mscorlib|netstandard|HarfBuzzSharp|Silk\.NET)(\.|$)</HostSharedPattern>
</PropertyGroup>
<ItemGroup>
<PluginFiles Include="$(TargetDir)$(TargetName).dll;$(TargetDir)$(TargetName).pdb;$(TargetDir)$(TargetName).deps.json" />
<PluginDependencies Include="@(ReferenceCopyLocalPaths)" Condition="!$([System.Text.RegularExpressions.Regex]::IsMatch('%(ReferenceCopyLocalPaths.Filename)', '$(HostSharedPattern)')) And !$([System.Text.RegularExpressions.Regex]::IsMatch('%(ReferenceCopyLocalPaths.NuGetPackageId)', '$(HostSharedPattern)'))" />
</ItemGroup>
<Copy SourceFiles="@(PluginFiles)" DestinationFolder="$(PluginDestination)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginDependencies)" DestinationFiles="@(PluginDependencies->'$(PluginDestination)%(DestinationSubDirectory)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
  • It installs into the same folder as the runtime assembly. Building it builds the runtime project first, so one dotnet build plugins-src/Spinners.Editor installs both.
  • Talesmith.*.dll includes Talesmith.Editor and Talesmith.UI, the editor and its controls. CommunityToolkit.Mvvm is needed as soon as you use editor types built on it, such as the viewport tool context.
  • The runtime project must never reference the editor project or editor assemblies. Exported games do not contain them, and the engine warns when a runtime assembly references Talesmith.Editor, Talesmith.UI, Talesmith.Build, Talesmith.Scripting.Compiler or Talesmith.App.

Copying into a game​

The InstallPlugin target copies the plugin's assembly, symbols, .deps.json and plugin.json into assets/plugins/<folder> of a game, so every build is installed at once. It also copies the build's ReferenceCopyLocalPaths, the files of the plugin's NuGet packages and other private libraries, keeping subfolders such as runtimes/. It leaves out every file whose name or package id starts with a name the host shares (Talesmith, Microsoft.Extensions, Avalonia, SkiaSharp, CommunityToolkit, System, mscorlib, netstandard) or with one of the engine's native packages (HarfBuzzSharp, Silk.NET), because the plugin always gets those from the host. The repository's samples share the same step in samples/Plugin.targets, which each sample project imports:

samples/Plugin.targets
<Project>
<PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<PluginGame Condition="'$(PluginGame)' == ''">HexQuest</PluginGame>
<PluginDestination>$(MSBuildThisFileDirectory)$(PluginGame)/assets/plugins/$(PluginFolder)/</PluginDestination>
<HostSharedPattern>^(Talesmith|Microsoft\.Extensions|Avalonia|SkiaSharp|CommunityToolkit|System|mscorlib|netstandard|HarfBuzzSharp|Silk\.NET)(\.|$)</HostSharedPattern>
</PropertyGroup>
<ItemGroup>
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(UseSharedSampleCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)Shared/*.cs" LinkBase="Shared" />
</ItemGroup>
<Target Name="CopyPluginToGame" AfterTargets="Build">
<ItemGroup>
<PluginFiles Include="$(TargetDir)$(TargetName).dll;$(TargetDir)$(TargetName).pdb;$(TargetDir)$(TargetName).deps.json;$(TargetDir)plugin.json" />
<PluginDependencies Include="@(ReferenceCopyLocalPaths)"
Condition="!$([System.Text.RegularExpressions.Regex]::IsMatch('%(ReferenceCopyLocalPaths.Filename)', '$(HostSharedPattern)')) And !$([System.Text.RegularExpressions.Regex]::IsMatch('%(ReferenceCopyLocalPaths.NuGetPackageId)', '$(HostSharedPattern)'))" />
</ItemGroup>
<Copy SourceFiles="@(PluginFiles)" DestinationFolder="$(PluginDestination)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginDependencies)" DestinationFiles="@(PluginDependencies->'$(PluginDestination)%(DestinationSubDirectory)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>

A sample project sets PluginFolder (the folder name under assets/plugins), optionally PluginGame (the game under samples, Hex Quest by default) and UseSharedSampleCode to compile the pause menu in samples/Shared into the plugin:

samples/Talesmith.Samples.IsleHopper/Talesmith.Samples.IsleHopper.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PluginFolder>islehopper</PluginFolder>
<PluginGame>IsleHopper</PluginGame>
<UseSharedSampleCode>true</UseSharedSampleCode>
</PropertyGroup>
<Import Project="..\Plugin.targets" />
<ItemGroup>
<ProjectReference Include="..\..\src\Talesmith.Runtime\Talesmith.Runtime.csproj" Private="false" ExcludeAssets="runtime" />
<ProjectReference Include="..\..\src\Talesmith.Avalonia\Talesmith.Avalonia.csproj" Private="false" ExcludeAssets="runtime" />
</ItemGroup>
</Project>

To install one plugin into several games, call the Copy task once per destination, or build the plugin once and copy its folder; see Packaging and distribution.