Tritone is a clean, modular, high-performance game framework for Unity and .NET.
Add https://github.com/TristinOrg/Tritone.git through Unity Package Manager's Add package from git URL command. Once a release tag is published, append that tag for reproducible production projects; use the untagged URL only when intentionally tracking main.
The Unity Package GitHub workflow validates package metadata and GUID integrity, then runs EditMode and PlayMode tests with Unity 2022.3. Configure the repository secrets UNITY_LICENSE, UNITY_EMAIL, and UNITY_PASSWORD to enable Unity test jobs; without them, package validation still runs and Unity jobs report an explicit skip warning.
The first milestone provides:
- A pure C# application lifecycle with no Unity dependency.
- Explicit module dependencies with topological startup order.
- Reverse-order shutdown and partial-start rollback.
- A small singleton service registry.
- Allocation-free PreUpdate, Update, LateUpdate, and FixedUpdate dispatch.
- A Unity bootstrap component.
- A filtered, multi-sink diagnostic logging service.
Create a module:
using Tritone.Kernel;
public sealed class HelloModule : ModuleBase, IUpdateSystem
{
public int Order => 0;
protected override ELogLevel LogLevel => ELogLevel.Debug;
protected override void OnStart()
{
Logger.Info("Hello module started.");
}
public void Update(in FrameTime time)
{
}
protected override void OnStop()
{
Logger.Info("Hello module stopped.");
}
}Create the project entry point:
using Tritone.Diagnostics;
using Tritone.Kernel;
using Tritone.Unity;
public sealed class GameBootstrap : TritoneBootstrap
{
protected override void Configure(GameApplicationBuilder builder)
{
builder.UseLogging(ELogLevel.Debug, new UnityLogSink());
builder.AddModule(new HelloModule());
}
}Attach GameBootstrap to one GameObject in the startup scene and enter Play Mode.
Each module receives an independent ModuleContext. The context composes
feature capabilities and one generic ModuleScope:
ModuleBase
-> ModuleContext
-> Assets / Timers / UI / Network / other capabilities
-> Service contracts
-> ModuleScope ownership
ModuleBase keeps familiar helpers such as LoadAsset, SetTimer, and
BindMessage as compatibility facades. Their implementation and ownership now
live in focused capabilities. New framework features should follow the same
boundary: service contracts provide shared behavior, capabilities adapt that
behavior for a module, and the module scope releases acquired resources.
Modules may also use a capability explicitly when that makes a dependency clearer:
var prefab = await Context.Assets.LoadAsync<GameObject>("Characters/Player");
Context.Events.Bind(player.Events.HealthChanged, OnHealthChanged);Unused capabilities are created lazily and allocate no domain-specific scope.
Configure one bounded dispatcher before modules that consume asynchronous services:
builder.UseMainThreadDispatcher(maxCallbacksPerUpdate: 4096);Post a method group from a worker-thread completion and let module ownership cancel it automatically if the module stops first:
private void OnDownloadCompleted()
{
PostToMainThread(ApplyDownloadedContent);
}
private void ApplyDownloadedContent()
{
// Safe to access Unity objects here.
}Context.MainThread.Post(callback) returns a readonly DispatchHandle for explicit cancellation or pending-state queries. Producers enqueue a compact struct through a thread-safe queue; callbacks execute in deterministic FIFO order during PreUpdate. The per-frame limit prevents an unbounded worker backlog from monopolizing one frame, and one callback failure does not stop later work.
Register shared state with an explicit factory and ownership lifetime:
builder.AddApplicationModel<PlayerModel>();
builder.AddSceneModel(() => new BattleModel(mBattleRules));Models implement a deterministic lifecycle without depending on Unity:
public sealed class PlayerModel : IModel
{
public readonly Event<int> LevelChanged = new();
public int Level { get; private set; }
public void Initialize()
{
Level = 1;
}
public void Reset()
{
Level = 1;
LevelChanged.Clear();
}
public void Dispose()
{
LevelChanged.Clear();
}
}Resolve a model directly from a module capability or use the compatibility facade:
var player = Context.Models.Get<PlayerModel>();
var battle = GetModel<BattleModel>();Models are created only on first access and shared by concrete registered type. Application models survive scene changes and release in reverse creation order when the application stops. Scene models require an active scene module and are released before the next scene module starts. Consumers never own shared model instances, so stopping one module cannot invalidate state still used elsewhere. Initialization failures dispose the incomplete instance and leave registration available for a later retry.
Register application flows explicitly:
builder.AddFlow(() => new LoginFlow(mLoginConfig));
builder.AddFlow<LobbyFlow>();
builder.AddFlow<BattleFlow>();A flow owns one high-level application stage:
public sealed class LoginFlow : IFlow
{
public async Task EnterAsync(CancellationToken cancellationToken)
{
await LoadLoginDataAsync(cancellationToken);
}
public void Update(in FrameTime time)
{
}
public void Exit()
{
}
public void Dispose()
{
}
}Switch from a module through its capability or compatibility facade:
await Context.Flows.SwitchAsync<LoginFlow>(cancellationToken);
await SwitchFlowAsync<BattleFlow>(cancellationToken);Only one flow is active at a time. Identical concurrent requests share the running transition, while conflicting targets are rejected. The current flow exits before the target enters, but is retained until entry succeeds. Failed or cancelled entry disposes the incomplete target and re-enters the previous flow. The active flow updates before normal module updates and exits automatically when the application stops.
Register struct component data and ordered systems explicitly for the world lifetime that owns them:
builder.UseEntities(initialCapacity: 256);
builder.AddApplicationComponent<PlayerIdentity>();
builder.AddSceneComponent<Position>();
builder.AddSceneComponent<Velocity>();
builder.AddSceneEntitySystem<MovementSystem>();Component data remains plain value types:
public struct Position : IEntityComponent
{
public float X;
public float Y;
}Create entities and mutate components by reference:
EntityWorld world = Context.Entities.Scene;
EntityId entity = world.Create();
world.Add(entity, new Position { X = 10.0f });
ref Position position = ref world.Get<Position>(entity);
position.X += 5.0f;Queries are lightweight structs. Indexed traversal does not allocate an enumerator and exposes component references directly:
var query = world.Query<Position, Velocity>();
for (int i = 0, cnt = query.CandidateCount; i < cnt; i++)
{
if (!query.TryGetEntity(i, out var entity))
continue;
ref Position position = ref query.GetFirst(entity);
ref Velocity velocity = ref query.GetSecond(entity);
position.X += velocity.X;
}EntityId contains a slot index and generation, so a destroyed identifier
cannot access a later entity that reuses the same slot. Application worlds
survive scene changes. Scene worlds are created before their scene module
configures and release all systems, entities, and components after that module
stops. Entity systems initialize in stable order, update before normal modules,
and shut down in reverse order.
Enable the shared tween scheduler once:
builder.UseTweens();Tween any numeric target through a module-owned setter:
Tween(
0.0f,
1.0f,
0.25,
value => mCanvasGroup.alpha = value,
ETweenEase.OutCubic);The scheduler is independent of Unity types, so the same API can animate UI, Transforms, audio values, model data, or pure simulation state. Cache method group delegates on frequently created tweens when avoiding caller-side closure allocations matters.
Build an immutable sequence outside per-frame paths and reuse it:
TweenSequence sequence = new TweenSequenceBuilder()
.Append(0.0f, 1.0f, 0.2, SetAlpha, ETweenEase.OutQuad)
.AppendDelay(0.1)
.AppendCallback(OnShown)
.Build();
TweenHandle handle = PlayTweenSequence(sequence);
PauseTween(handle);
ResumeTween(handle);
CancelTween(handle);Sequences carry unused frame time across step boundaries and support finite or infinite loops. Scaled and unscaled clocks are available. Scheduling during a tween callback begins on the next application update, preventing recursive same-frame execution. Setter and completion failures are isolated to their own tween. Every active tween is automatically cancelled when its owning module or scene module stops.
Configure sampled runtime state and a fixed recent-log buffer. Additional sinks can continue forwarding the same events to the Unity Console:
builder.UseRuntimeDiagnostics(
ELogLevel.Debug,
logCapacity: 256,
sampleWindow: 0.5,
new UnityLogSink());Read the latest allocation-free snapshot from code:
var diagnostics = application.Services.GetRequired<IRuntimeDiagnosticsService>();
var snapshot = diagnostics.Snapshot;
Logger.Info($"FPS: {snapshot.FramesPerSecond:F1}");
Logger.Info($"Scene entities: {snapshot.SceneEntities}");The snapshot reports application state, active scene module, active flow, application and scene entity counts, FPS, average/minimum/maximum frame time, and frame index. Recent log events are retained in chronological order by a fixed-capacity ring buffer and overwrite the oldest event when full.
Add RuntimeDiagnosticsOverlay to the bootstrap GameObject for an optional
runtime IMGUI panel. Press F3 by default to toggle it. The panel is deliberately
separate from the diagnostic service: projects can replace it with their own UI
without changing logging or sampling infrastructure.
Register the shared timer scheduler once:
builder.UseTimers();
builder.AddModule(new GameplayModule());Use integer or string keys directly from any ModuleBase implementation:
protected override void OnStart()
{
SetTimer("Refresh", 2.0, OnRefresh);
SetRepeatedTimer(1001, 1.0, OnSecondPassed);
}
private void OnSecondPassed()
{
Logger.Debug("One second passed.");
}
private void OnRefresh()
{
CancelTimer(1001);
}Each module can own multiple keyed timers. Setting the same key replaces its previous timer, and stopping the module automatically cancels every timer it owns and clears its key cache.
Use cached method-group delegates on frequently scheduled paths. Capturing lambdas can allocate even though the timer scheduler itself reuses preallocated storage.
Keep strongly typed events together in the module that owns them:
public sealed class PlayerModule : ModuleBase
{
public EventsList EventsList { get; } = new();
public sealed class EventsList
{
public readonly Event<int> HealthChanged = new();
public readonly Event<int, PlayerData> PlayerDied = new();
}
private void NotifyHealthChanged(int health)
{
EventsList.HealthChanged.Publish(health);
}
}Keep prefab references in a view without adding business logic:
public sealed class UIShopView : UIView
{
public Button BtnClose;
public Button BtnRule;
public RectTransform NodePanels;
}For prefab-driven authoring, add UIPrefabRef to the UI prefab root. Drag child
GameObjects or Components into its inspector, select the exact component type,
then use these actions:
Generate UIView Scriptwrites a deterministic// <auto-generated />view source to the configured Assets directory.- After Unity compiles,
Bind Generated UIViewadds the generated component and assigns every strongly typed reference. Preprocess Sorting Hierarchyrecords all nested Canvas and Renderer nodes in their authored visual order and hierarchy depth.
Opening a window moves it to the top of its logical UI layer. Tritone then
assigns consecutive runtime sorting orders to the preprocessed nodes. Each
EUILayer owns a separate sorting-order interval, preventing nested canvases,
particles, sprites, and trails in one window from colliding with another layer.
Run preprocessing again after changing the prefab render hierarchy or authored
sorting orders.
Bind Unity controls and Tritone events in the window's dedicated binding stage:
public sealed class ShopWindow : UIWindow<UIShopView>
{
protected override void OnBindEvents()
{
BindButton(mView.BtnClose, Close);
BindButton(mView.BtnRule, OnRule);
var playerModule = GetModule<PlayerModule>();
BindEvent(playerModule.EventsList.HealthChanged, OnHealthChanged);
}
private void OnHealthChanged(int health)
{
}
private void OnRule()
{
}
}UIElement<TView> resolves the view once, binds listeners during OnBindEvents, and releases every Unity and Tritone listener when disabled. ModuleBase releases all of its bindings when the module stops.
Register reusable item templates and single-instance panels once per window:
public sealed class InventoryWindow : UIWindow<UIInventoryView>
{
protected override void OnInitialize()
{
AddItemTemplate<InventoryItem>("UI/Inventory/InventoryItem");
AddPanel<ItemDetailPanel>("UI/Inventory/ItemDetailPanel", mView.PanelRoot);
}
private void RefreshItem()
{
var item = CreateItem<InventoryItem>(mView.Content);
OpenPanel<ItemDetailPanel>();
ReleaseItem(ref item);
}
}Item prefabs load lazily and reuse the shared prefab pool. Panels are created lazily and retain one instance during a window activity. Closing a window returns all composed instances; releasing the window also releases loaded template assets. Dynamic child views participate in the owning window's sorting-order sequence.
Enable assets and UI once in the bootstrap:
protected override void Configure(GameApplicationBuilder builder)
{
builder.UseAssets();
builder.UsePools();
builder.UseUI(mUIRoot);
builder.AddModule(new ShopModule());
}Each module registers the windows it owns without modifying a central catalog:
public sealed class ShopModule : ModuleBase
{
protected override void OnConfigure(IServiceRegistry services)
{
AddWindow<ShopWindow>("UI/Shop/ShopWindow", EUILayer.Normal);
}
private void ShowShop()
{
OpenWindow<ShopWindow>();
}
private async Task ShowShopAsync()
{
await OpenWindowAsync<ShopWindow>();
}
}Module windows are available while at least one active module owns the matching definition. Repeated registrations must use the same path, layer, and lifetime. Releasing the final owner closes the window, destroys its cached instance, releases its prefab, and removes the definition so a later hot-update module can register a new path. Application windows remain available until the application stops.
Persistent infrastructure modules start with the application. Scene modules are registered as factories and created only when entered:
builder.UseTimers();
builder.UseAssets();
builder.UseUI(mUIRoot);
builder.UseScenes();
builder.AddSceneModule<LoginModule>();
builder.AddSceneModule(() => new BattleModule(mBattleConfig));Load the target Unity scene and activate its module from a ModuleBase or TritoneComponent:
await SwitchSceneAsync<LoginModule>("Login", OnSceneProgress);
await SwitchSceneAsync<BattleModule>("Battle");The target scene loads additively before the previous module stops. Tritone then makes it active, creates its fresh scene module, and unloads the previous scene. Identical concurrent requests share one operation, while conflicting targets are rejected. A loading failure leaves the previous scene and module untouched; a module startup failure restores the previous scene and recreates its module.
TritoneBootstrap automatically survives scene unloading. Scene module timers, event bindings, windows, pools, assets, and tables are released when that module stops. Data that must survive transitions should live in a persistent model module. Use SwitchModule<TModule>() only when changing logical modules without loading a Unity scene.
Enable shared lazy pools once without registering object types or prefabs:
builder.UsePools();Rent and return plain C# objects directly from a ModuleBase:
var damageData = Rent<DamageData>();
Return(ref damageData);Spawn and despawn Unity Component or GameObject prefabs without prior pool registration:
var effect = Spawn(mDamageEffectPrefab, mEffectRoot);
Despawn(ref effect);The ref overload clears the caller's reference after a successful return. The first request creates the matching type or prefab pool. Objects left active are automatically returned when their owning module or TritoneComponent is released. UIElement also returns everything spawned during its current enabled lifetime when it closes, so temporary UI children do not require manual despawn calls. Implement IPoolable only when an object needs spawn and despawn reset callbacks.
Configure the four optional services once:
builder.UseAssets();
builder.UseAudio();
builder.UseSaves();
builder.UseSettings();
builder.UseTables();
builder.UseLocalization("en");Use audio directly from a module or Tritone component:
PlayMusic("Audio/Music/Login");
var click = PlaySound("Audio/SFX/Click");
StopSound(click);Save strongly typed data atomically:
Save("slot1", playerSave);
if (TryLoadSave<PlayerSave>("slot1", out var loaded))
ApplySave(loaded);Settings remain in memory until Settings.Save() or application shutdown:
Settings.SetFloat("MusicVolume", 0.8f);
Settings.SetBool("Muted", false);
Settings.SetString("Language", "zh-CN");Localization reads one hot-updateable JSON table per language from
Localization/{language}:
var title = Localize("UI.Login.Title");
await SetLanguageAsync("zh-CN");Each localization file uses the same table JSON shape:
{
"Rows": [
{ "Id": "UI.Login.Title", "Text": "Login" }
]
}Enable asset management with the built-in Unity Resources provider:
builder.UseAssets();Load assets directly from a ModuleBase or TritoneComponent:
var config = LoadAsset<TextAsset>("Configs/GameConfig");
var prefab = await LoadAssetAsync<GameObject>("UI/LoginWindow");Repeated path and type requests share one cached load. Concurrent asynchronous calls also join the same provider operation. Manual release is optional:
ReleaseAsset(config);Every remaining reference is released automatically when its owning module stops or its TritoneComponent is destroyed. Implement IAssetProvider and pass it to UseAssets(provider) when replacing Resources with Addressables or another backend.
Enable Addressables loading, remote catalog updates, and dependency preloads together:
builder.UseAddressableAssets();
var catalogs = application.Services.GetRequired<IAddressablesCatalogService>();
await catalogs.UpdateCatalogsAsync(cancellationToken);
var downloads = application.Services.GetRequired<IAddressablesDownloadService>();
await downloads.DownloadAsync("startup", OnDownloadProgress, cancellationToken);
await downloads.ClearCacheAsync("startup", cancellationToken);Preloads skip cached content, report byte progress with a readonly struct, and release every Addressables operation handle.
Create Assets/Tritone/Tables.json and run Tritone/Generate/Tables to generate
strongly typed rows without manually maintaining boilerplate:
{
"Namespace": "Game.Tables",
"OutputPath": "Assets/Generated/Tritone/Tables",
"DataOutputPath": "Assets/Resources/Tables",
"SourceDirectories": [
"Assets/GameData/Tables"
]
}Every CSV and TSV below a configured directory is discovered recursively. The file
name becomes the generated table type, the relative file path becomes the generated
JSON path, and the source directory name prefixes the runtime asset path. For example,
Assets/GameData/Tables/Items/Weapons.csv generates WeaponsRow,
Assets/Resources/Tables/Items/Weapons.json, and runtime path
Tables/Items/Weapons.
Each CSV or TSV is self-describing. Its first row contains field names, its second row contains field types, and remaining rows contain data. The first column is the stable primary key:
Id,Name,Enabled
int,string,bool
1001,tristin,trueThe compiler validates field types, required cells, and duplicate keys before committing any output, then writes
the generated row source and runtime-compatible { "Rows": [...] } JSON together.
Built-in field types are bool, int, long, float, double, and string.
Explicit Fields remain supported for compatibility and for code-only generation.
Generated files are rewritten only when their contents change.
Removed table definitions also remove their former generated source files.
For project-specific formats, compose a compiler explicitly with
TableCompilerBuilder. Source readers, field types, validators, code generators,
and data writers are independent interfaces, so extensions do not require changes
to Tritone's runtime table module.
Enable tables after configuring either Resources or content-managed assets:
builder.UseAssets();
builder.UseTables();Describe a row with its stable primary key:
[Serializable]
public sealed class RoleRow : ITableRow<int>
{
public int Id;
public string Name;
public int Key => Id;
}Store the rows in a UTF-8 JSON TextAsset:
{
"Rows": [
{ "Id": 1001, "Name": "Tristin" },
{ "Id": 1002, "Name": "Aigis" }
]
}Load and query the table directly from any ModuleBase:
var roles = LoadTable<int, RoleRow>("Tables/Roles");
var tristin = roles.Get(1001);
if (roles.TryGet(1002, out var aigis))
Logger.Info(aigis.Name);Asynchronous loading uses the same ownership model:
var roles = await LoadTableAsync<int, RoleRow>("Tables/Roles");The first load deserializes and indexes the rows once. Repeated loads share the parsed table, and concurrent asynchronous requests join one operation. Primary-key lookup is constant time, while GetAt(index) supports allocation-free source-order traversal. Duplicate keys fail immediately during indexing.
Manual release is optional. ReleaseTable(ref roles) releases one owned reference and clears the caller. Any remaining tables and their underlying TextAsset references are released automatically when the owning module stops. Pass a custom ITableDeserializer to UseTables(deserializer) when switching from readable JSON to a generated binary format; gameplay loading and lookup code stays unchanged.
Create Assets/Tritone/Network.json and run
Tritone/Generate/Network Messages:
{
"ProtocolId": "game-main",
"MajorVersion": 1,
"MinorVersion": 2,
"MinimumMinorVersion": 1,
"Namespace": "Game.Network",
"OutputPath": "Assets/Generated/Tritone/Network",
"Messages": [
{
"Id": 1001,
"Name": "LoginRequest",
"Kind": "Request",
"Response": "LoginResponse",
"Fields": [
{ "Name": "Account", "Type": "string" }
]
},
{
"Id": 1002,
"Name": "LoginResponse",
"Kind": "Response",
"Fields": [
{ "Name": "Token", "Type": "string" }
]
}
]
}Register every generated codec once:
MessageSerializer serializer = new();
NetworkMessages.Register(serializer);Exchange the generated descriptor during the connection or login handshake and reject incompatible peers before sending gameplay messages:
var compatibility = NetworkMessages.Protocol.EvaluateCompatibility(in remoteProtocol);
if (compatibility != ENetworkProtocolCompatibility.Compatible)
throw new InvalidOperationException($"Network protocol rejected: {compatibility}");Major versions must match. Each peer's minor version must fall within the other peer's declared compatibility range. Equal version numbers must also have the same deterministic SHA-256 schema fingerprint, which catches wire changes made without incrementing the protocol version.
Enable automatic validation after the transport connects and before gameplay traffic is available:
var options = new NetworkSessionOptions()
.UseProtocolHandshake(in NetworkMessages.Protocol, timeoutSeconds: 10.0)
.UseReconnect();
builder.UseTcpNetwork(serializer, options: options);The client sends a framework-owned control frame after every initial connection and reconnect. ConnectAsync completes only after the server accepts the protocol. Incompatible peers throw NetworkProtocolHandshakeException and are disconnected. Application messages and requests are rejected until validation succeeds.
A Tritone-compatible server handles the same frame without depending on Unity:
if (NetworkProtocolHandshakeFrame.TryReadHello(frame, out var clientProtocol))
{
var response = NetworkProtocolHandshakeFrame.CreateResponse(in ServerProtocol, in clientProtocol);
await connection.SendAsync(response);
}Use UseHandshake(customHandshake) when authentication or platform tickets must be composed into a project-specific connection validation strategy.
Generated request relationships let modules infer the response type:
LoginResponse response = await RequestAsync(
new LoginRequest { Account = "Tristin" });Message IDs, duplicate names, request-response relationships, and supported
field types are validated before output. Supported network field types are
bool, byte[], float, int, and string.
Run Tritone/Generate/All when both schemas are present to update tables and
network messages with a single Unity asset refresh.
Compose bundle and asset registrations before building the application:
AssetBundleRegistry registry = new();
registry.AddBundle("core", "core.bundle")
.AddBundle("shared", "shared.bundle", "core")
.AddBundle("ui", "ui.bundle", "shared")
.AddAsset("UI/LoginWindow", "ui", "Assets/Game/UI/LoginWindow.prefab");
var bundleRoot = Path.Combine(Application.persistentDataPath, "Bundles");
var source = new FileAssetBundleSource(bundleRoot);
var provider = new AssetBundleAssetProvider(registry, source);
builder.UseAssets(provider);Game modules continue using the same API:
var prefab = await LoadAssetAsync<GameObject>("UI/LoginWindow");The registry validates missing dependencies and cycles once, then precomputes a unique dependency-first load order. Assets share loaded bundles and in-flight bundle requests. Releasing the final asset unloads its root bundle and dependencies in reverse order. The registry becomes immutable after provider construction; feature code may compose registrations before that point without editing a central ScriptableObject. FileAssetBundleSource is the local first-stage source. A later remote source can implement IAssetBundleSource without changing AssetModule or gameplay calls.
Describe one installed or remote content version with immutable bundle and asset entries:
var remoteManifest = new ContentManifest(
"1.1.0",
new[]
{
new ContentBundle("core", "core.bundle", coreHash, coreSize),
new ContentBundle("ui", "ui.bundle", uiHash, uiSize, "core")
},
new[]
{
new ContentAsset("UI/LoginWindow", "ui", "Assets/Game/UI/LoginWindow.prefab")
});Compare the installed manifest with the remote manifest before downloading files:
var plan = ContentUpdatePlanner.CreatePlan(localManifest, remoteManifest);
for (int i = 0, cnt = plan.Downloads.Count; i < cnt; i++)
{
var bundle = plan.Downloads[i];
// The transactional updater downloads and verifies this file.
}Planning compares bundle file names, hashes, and sizes instead of trusting the version label alone. It reuses identical local files after logical bundle renames, reports obsolete files separately, and preserves remote manifest order for deterministic downloads. After a successful update, create the loading registry directly from the active manifest:
var registry = remoteManifest.CreateAssetBundleRegistry();
var provider = new AssetBundleAssetProvider(registry, source);
builder.UseAssets(provider);Configure remote updates and local AssetBundle loading together. Do not also call UseAssets:
var localRoot = Path.Combine(Application.persistentDataPath, "Content");
ContentUpdateOptions options = new(
"https://cdn.example.com/Windows/content-manifest.json",
"https://cdn.example.com/Windows/",
localRoot);
builder.UseContentAssets(options);
builder.AddModule(new StartupModule());Start the update from a normal module without writing async void or managing cancellation:
public sealed class StartupModule : ModuleBase
{
protected override void OnStart()
{
StartContentUpdate(OnContentReady,
OnContentProgress,
OnContentFailed);
}
private void OnContentReady(ContentUpdateResult result)
{
SwitchModule<LoginModule>();
}
private void OnContentProgress(ContentUpdateProgress progress)
{
Logger.Info(
$"Content: {progress.NormalizedProgress:P0}");
}
private void OnContentFailed(Exception exception)
{
Logger.Error("Content update failed.", exception);
}
}Use await UpdateContentAsync(OnContentProgress) instead when the caller already owns an asynchronous startup flow. Stopping the module automatically cancels its request. The shared updater serializes concurrent checks, streams downloads directly to temporary files, verifies exact size and lowercase SHA-256, and only then enters its non-cancellable commit stage.
The transaction keeps backups and creation markers beneath .tritone-update. A failed verification leaves active files untouched. A failed commit restores every replaced or removed file and the previous manifest. If the process terminates during commit, the next update check recovers the unfinished transaction before contacting the server. An update gate rejects the operation while old assets are loaded or loading, blocks new loads during the transaction, and activates the new manifest only after every bundle operation succeeds.
The installed manifest is loaded during application construction, so an existing verified version remains available when the remote check fails. Run content updates before loading content assets; changing files on disk cannot replace AssetBundles or prefab instances that are already in memory.
Assign AssetBundle names through Unity's import settings, set PlayerSettings.bundleVersion, then run Tritone > Build > Content for Active Platform. Tritone writes bundles and a runtime-compatible manifest.json under ContentBuilds/<platform>/<version>. Bundle entries contain deterministic direct dependencies, file sizes, and lowercase SHA-256 hashes. By default each project-relative asset path is also its public address.
Build automation can provide explicit stable addresses and choose its own output path:
var assets = new[]
{
new ContentBuildAsset("UI/Login", "Assets/UI/Login.prefab")
};
var options = new ContentBuildOptions("1.2.0", outputPath, BuildTarget.StandaloneWindows64, assets: assets);
var result = ContentBuildPipeline.Build(options);The build fails before publishing a manifest when a configured asset is not assigned to a built bundle, an output bundle is missing, or the generated runtime manifest is invalid. Upload the version directory as-is and point ContentUpdateOptions at its manifest.json.