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:
<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>
| Line | Why |
|---|---|
TargetFramework net10.0 | The engine runs on .NET 10. A plugin cannot target a newer framework than the host. |
EnableDynamicLoading | Marks 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, ManagePackageVersionsCentrally | Keep 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. |
InstallPlugin | After 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,CommunityToolkitandSystem, or starting with one of those names and a dot, always come from the host. That keeps oneIPlugin, oneWorldand oneIServiceCollectiontype for everyone.
Reference what your code uses. The runtime assemblies are:
| Assembly | Contains |
|---|---|
Talesmith.Core | ECS (World, queries), systems, IPlugin, authoring attributes, time, events, math |
Talesmith.Runtime | The game host, scenes, built-in components such as Transform and Camera, serialization and value converters |
Talesmith.Plugins | PluginLoader, PluginManager, PluginSettings, PluginPermissionNames |
Talesmith.Assets | The asset manager, importers, asset kinds, localization |
Talesmith.Rendering, Talesmith.Grids | Cameras, colors and render types; hex and square grids |
Talesmith.Input, Talesmith.Audio | Input and audio services |
Talesmith.Physics, Talesmith.Lighting, Talesmith.VFX | Physics, lights and particles |
Talesmith.Scripting | Script, for plugins that ship scripts |
Talesmith.Avalonia | IGameOverlay; add Avalonia.Base.dll and Avalonia.Controls.dll to write overlays |
Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Logging.Abstractions | IServiceCollection, 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.
[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:
<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.Editorinstalls both. Talesmith.*.dllincludesTalesmith.EditorandTalesmith.UI, the editor and its controls.CommunityToolkit.Mvvmis 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.CompilerorTalesmith.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:
<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:
<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.