SSAL.Testing
1.0.0
dotnet add package SSAL.Testing --version 1.0.0
NuGet\Install-Package SSAL.Testing -Version 1.0.0
<PackageReference Include="SSAL.Testing" Version="1.0.0" />
<PackageVersion Include="SSAL.Testing" Version="1.0.0" />
<PackageReference Include="SSAL.Testing" />
paket add SSAL.Testing --version 1.0.0
#r "nuget: SSAL.Testing, 1.0.0"
#:package SSAL.Testing@1.0.0
#addin nuget:?package=SSAL.Testing&version=1.0.0
#tool nuget:?package=SSAL.Testing&version=1.0.0
SSAL — s&box Shared API Layer
Status: Release · Revision 1.0.0
A versioned, interface-driven C# library that gives addon creators and gamemode developers a single shared contract to target, instead of depending on each other.
Table of Contents
- Overview
- Why This Exists
- Package Structure
- Quick Start
- Core Interfaces
- Standard Events
- Service Registry
- Versioning
- Roslyn Analyzer Rules
- Testing
- Migrating from Ad-Hoc Interop
- Roadmap
- Full Interface Reference
- Contributing
Overview
s&box encourages a modular ecosystem. A user running a survival gamemode might simultaneously equip addons for a custom inventory UI, a weather system, and a voice-chat overlay, all authored by different people, all executing inside the same C# scripting sandbox.
Without a formal contract, these packages discover each other through reflection, dynamic casts, and copy-pasted stubs. The result is silent runtime failures, versioning nightmares, and a fragmented ecosystem.
SSAL solves this with three focused packages that define the vocabulary for cross-boundary communication: types, interfaces, events, and service locators that turn ad-hoc calls into well-typed, discoverable, forward-compatible contracts.
Why This Exists
| Without SAL | With SAL |
|---|---|
Type.GetMethod("GetInventory").Invoke(...) |
ctx.GetService<IInventory>() |
| Static events tied to a specific gamemode class | ctx.Events.Subscribe<PlayerDiedEvent>(...) |
Silent NullReferenceException at runtime |
Compile-time error via Roslyn analyzer |
| No way to know if a gamemode supports a feature | gamemode.json declares "provides": [...] |
| Reflection breaks on every gamemode update | Interfaces are versioned with [ApiVersion] |
Package Structure
SSAL
├── SSAL.Contracts ← Both addons AND gamemodes reference this
├── SSAL.Core ← Gamemodes only (ServiceRegistry, EventBus, BootstrapHost)
└── SSAL.Testing ← Test projects only (FakeGameContext, FakeInventory, ...)
Dependency rule: Addons depend on Contracts only. Gamemodes depend on Contracts + Core. This direction is never reversed and is enforced by the Roslyn analyzer (SAL001).
s&box Runtime / Sandbox.*
│
SSAL.Core ← Gamemode layer
ServiceRegistry | EventBus | BootstrapHost
│ implements / references
SSAL.Contracts ← Shared surface
IGameContext | IInventory | IPlayerState | IEventChannel
│ │
Gamemode A Addon Pack B
Quick Start
For Addon Authors
Add only the Contracts package to your .csproj:
<ItemGroup>
<PackageReference Include="SSAL.Contracts" Version="1.*" />
<PackageReference Include="SSAL.Analyzers" Version="1.*" PrivateAssets="all" />
</ItemGroup>
Implement IAddon as your entry point:
[AddonEntry]
public sealed class WeatherAddon : IAddon
{
private IEventChannel? _events;
public void OnLoad(IGameContext ctx)
{
_events = ctx.Events;
// Optional service — gracefully degrade if not present
var scoreboard = ctx.GetService<IScoreboard>();
if (scoreboard is not null)
HookScoreboardWeatherBonus(scoreboard);
_events.Subscribe<RoundStartedEvent>(OnRoundStarted);
}
public void OnUnload() { /* clean up subscriptions */ }
private void OnRoundStarted(RoundStartedEvent e)
=> SpawnWeatherForRound(e.RoundIndex);
}
Capability negotiation — never throw on a missing optional service:
public void OnLoad(IGameContext ctx)
{
// Hard requirement
var players = ctx.GetService<IPlayerRegistry>()
?? throw new AddonLoadException($"{Manifest.Name} requires IPlayerRegistry.");
// Soft requirement — disable the feature if absent
var teams = ctx.GetService<ITeamManager>();
if (teams is null)
{
Log.Warning("Team colors disabled: ITeamManager not registered.");
_teamColorsEnabled = false;
}
}
For Gamemode Developers
Reference both Contracts and Core:
<ItemGroup>
<PackageReference Include="SSAL.Contracts" Version="1.*" />
<PackageReference Include="SSAL.Core" Version="1.*" />
<PackageReference Include="SSAL.Analyzers" Version="1.*" PrivateAssets="all" />
</ItemGroup>
Register your services during startup and declare capabilities in gamemode.json:
public override void OnStart()
{
base.OnStart();
var registry = ServiceRegistry.Create();
registry.Register<IInventory>(() => new SurvivalInventory(maxSlots: 10));
registry.Register<ITeamManager>(() => new TwoTeamManager());
registry.Register<IScoreboard>(() => new SurvivalScoreboard());
GameContextHost.Initialize(registry);
}
{
"title": "Survival Wars",
"sal": {
"minVersion": "1.0",
"provides": ["IInventory", "ITeamManager", "IScoreboard", "IPlayerRegistry"]
}
}
Publish standard events when things happen:
private void HandlePlayerDeath(SurvivalPlayer victim, DamageInfo dmg)
{
victim.IsAliveInternal = false;
_context.Events.Publish(new PlayerDiedEvent
{
Player = victim,
DamageInfo = DamageInfoAdapter.FromSandbox(dmg),
Attacker = _playerRegistry.Find(dmg.Attacker),
Timestamp = DateTimeOffset.UtcNow,
});
}
Core Interfaces
IGameContext
Root context for a running session. Resolved by addons via ServiceRegistry.Current at load time.
[ApiVersion(1, 0)]
public interface IGameContext
{
Guid SessionId { get; }
T? GetService<T>() where T : class;
IEnumerable<T> GetServices<T>() where T : class;
IEventChannel Events { get; }
IGamemodeInfo GamemodeInfo { get; }
}
IPlayerState
Observable state of a single player — no concrete gamemode type needed.
[ApiVersion(1, 0)]
public interface IPlayerState
{
Guid PlayerId { get; }
string DisplayName { get; }
bool IsAlive { get; }
float Health { get; }
float MaxHealth { get; }
Vector3 Position { get; }
ITeam? Team { get; }
ITaggedDataBag Tags { get; } // gamemode-specific extension data
}
IInventory
[ApiVersion(1, 0)]
public interface IInventory
{
IReadOnlyList<IItem> Items { get; }
int MaxSlots { get; }
bool TryAdd(IItem item);
bool TryRemove(Guid itemId);
IItem? FindById(Guid itemId);
event EventHandler<InventoryChangedEventArgs>? Changed;
}
IEventChannel
Type-safe publish/subscribe. All inter-boundary communication that isn't a direct method call goes through here.
[ApiVersion(1, 0)]
public interface IEventChannel
{
IDisposable Subscribe<T>(Action<T> handler) where T : ISharedEvent;
void Publish<T>(T @event) where T : ISharedEvent;
ValueTask PublishAsync<T>(T @event, CancellationToken ct = default)
where T : ISharedEvent;
}
ITaggedDataBag
Extension data bag for gamemode-specific values. Avoids an ever-growing IPlayerState interface.
public interface ITaggedDataBag
{
bool HasTag(string key);
bool TryGet<T>(string key, out T? value);
void Set<T>(string key, T value);
void Remove(string key);
}
// Usage in an addon:
if (playerState.Tags.TryGet<int>("stamina", out var stamina))
RenderStaminaBar(stamina);
Standard Events
All events are defined in SSAL.Contracts. Gamemodes raise them; addons subscribe to them.
| Event | Description |
|---|---|
PlayerSpawnedEvent |
Player (re)spawned. Carries IPlayerState snapshot. |
PlayerDiedEvent |
Player died. Includes DamageInfo and attacker reference. |
RoundStartedEvent |
New round started. Carries round index and metadata. |
RoundEndedEvent |
Round concluded. Carries winning team or draw flag. |
ItemPickedUpEvent |
Player picked up an IItem. |
ItemDroppedEvent |
Player dropped an IItem. |
ScoreChangedEvent |
Any team or player score changed. |
GamemodePhaseChangedEvent |
Phase transition (e.g. Warmup → Live). |
ChatMessageEvent |
Text chat message. Addons may inspect or suppress. |
All event types implement ISharedEvent and should be immutable records.
Service Registry
The ServiceRegistry in SSAL.Core is a minimal IoC container with no runtime reflection. Three lifetime scopes are supported:
| Lifetime | Behaviour |
|---|---|
Singleton |
One instance per session (default). |
Transient |
New instance per GetService<T>() call. |
Scoped (Player) |
One instance per IPlayerState — useful for per-player inventory. |
Versioning
Every interface and event carries [ApiVersion(major, minor)]. The Roslyn analyzer warns at compile time when version mismatches are detected.
| Change | Policy |
|---|---|
Patch 1.0.x → 1.0.y |
Implementation bug fixes only. No interface changes. |
Minor 1.0 → 1.1 |
New members via C# 14 default interface methods, or new interfaces. Backward compatible. |
Major 1.x → 2.0 |
Removed or changed members. Addons on 1.x get compile-time errors. Migration guide required. |
Adding optional members without breaking existing implementors (minor bump):
// SAL 1.1 — backward compatible addition
[ApiVersion(1, 1)]
public interface IPlayerState
{
bool IsSprinting => false; // safe default for old gamemodes
float StaminaPercent => 1.0f;
}
Roslyn Analyzer Rules
SSAL.Analyzers ships with the package and enforces correct usage at compile time.
| ID | Severity | Rule |
|---|---|---|
SAL001 |
Error | Addon references Core package directly. Use Contracts only. |
SAL002 |
Warning | GetService<T>() result used without null check. Services may be absent. |
SAL003 |
Error | Concrete gamemode type referenced from addon. Use the interface. |
SAL004 |
Warning | ISharedEvent implementation has public setters. Events should be immutable records. |
SAL005 |
Error | Interface member added without bumping [ApiVersion] minor version. |
SAL006 |
Info | TaggedDataBag key is a magic string literal. Consider a constants class. |
Testing
SSAL.Testing provides pre-built fakes so addon authors can unit-test without a running s&box instance.
| Type | Purpose |
|---|---|
FakeGameContext |
In-memory IGameContext with a real EventChannel and configurable services. |
FakePlayerState |
Mutable IPlayerState for scenario setup. |
FakeInventory |
List-backed IInventory with event tracking. |
FakeTeamManager |
Pre-configured with Team.Red and Team.Blue; fully mutable. |
RecordingEventChannel |
Records all published events for assertion. |
public class WeatherAddonTests
{
[Fact]
public void SpawnsStorm_WhenRoundStartsWithIndex3()
{
var ctx = new FakeGameContext();
var addon = new WeatherAddon();
addon.OnLoad(ctx);
ctx.Events.Publish(new RoundStartedEvent { RoundIndex = 3 });
Assert.Equal(WeatherType.Storm, addon.CurrentWeather);
}
}
Migrating from Ad-Hoc Interop
Reflection → Service Resolution
// ❌ Before
var method = Game.Current.GetType().GetMethod("GetPlayerInventory");
var inv = method?.Invoke(Game.Current, new[] { playerId });
// ✅ After
var inv = ctx.GetService<IInventory>();
Custom Static Events → IEventChannel
// ❌ Before
SurvivalGame.OnPlayerDied += MyHandler;
// ✅ After — subscription is IDisposable, auto-cleaned on OnUnload
ctx.Events.Subscribe<PlayerDiedEvent>(MyHandler);
Roadmap
| Version | Scope |
|---|---|
| 1.0 | Core interfaces, ServiceRegistry, BootstrapHost, Roslyn analyzer (SAL001–006), Testing package, gamemode.json manifest schema. |
| 1.1 | IPhysicsInteraction, IRichPresence, stamina/sprint additions to IPlayerState via default interface methods, async event pipeline. |
| 1.2 | IRadioChannel (proximity voice contracts), IReplayRecorder hooks, SAL007 diagnostic for missing [AddonEntry]. |
| 2.0 | Source-generated proxy layer, renamed event args aligned to platform conventions, dotnet-sal-migrate CLI migration tool. |
Full Interface Reference
All types in SSAL.Contracts v1.0:
| Type | Kind | Description |
|---|---|---|
IGameContext |
Interface | Root session context and service locator. |
IGamemodeInfo |
Interface | Read-only metadata about the hosting gamemode. |
IAddon |
Interface | Lifecycle contract for addon entry points. |
IAddonManifest |
Interface | Parsed .addon.json descriptor. |
IPlayerState |
Interface | Observable state of a single player. |
IPlayerRegistry |
Interface | Collection of all connected IPlayerState instances. |
IInventory |
Interface | Slot-based item container. |
IItem |
Interface | Individual item with Id, DisplayName, Tags. |
ITeam |
Interface | Identifies a team; carries color and display name. |
ITeamManager |
Interface | Manages team membership and scoring. |
IScoreboard |
Interface | Read/write scoreboard for players and teams. |
IEventChannel |
Interface | Type-safe publish/subscribe event bus. |
ISharedEvent |
Interface | Marker interface for all SAL event types. |
ITaggedDataBag |
Interface | Typed extension data dictionary. |
PlayerSpawnedEvent |
Event | Raised when any player spawns or respawns. |
PlayerDiedEvent |
Event | Raised on player death with damage context. |
RoundStartedEvent |
Event | Raised at the start of each round. |
RoundEndedEvent |
Event | Raised at the end of each round. |
ItemPickedUpEvent |
Event | Raised when a player acquires an item. |
ItemDroppedEvent |
Event | Raised when a player drops an item. |
ScoreChangedEvent |
Event | Raised when any score value changes. |
GamemodePhaseChangedEvent |
Event | Raised on phase transitions. |
ChatMessageEvent |
Event | Raised for every in-game chat message. |
Contributing
This project is in draft — the interface surface is not yet frozen.
- Open an issue to propose new interfaces or events before implementing
- All PRs targeting
Contractsrequire a discussion thread first (changes affect the entire ecosystem) - Breaking changes (major version bumps) require a migration guide in
docs/migrations/ - Run
dotnet testand ensure all analyzer tests pass before opening a PR - Follow the dependency rule — addon-facing code must never reference
Core
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- SSAL.Contracts (>= 1.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 111 | 4/1/2026 |