Companion files:
CLAUDE.mdwraps this file for Claude Code — editAGENTS.md, notCLAUDE.md.README.mdis the user-facing entry point;docs/has deep per-service API references.
- Package:
com.gamelovers.services - Unity: 6000.0+
- Dependencies (see
package.json— versions here must stay in sync)com.gamelovers.gamedata(1.0.0) — providesfloatP, used byRngServicecom.unity.addressables(1.21.20) — asset loading and scene loadingcom.cysharp.unitask(2.5.10) — async/await support for asset loading
This package provides a set of small, modular "foundation services" for Unity projects (service locator/DI-lite, messaging, ticking, coroutines, pooling, persistence, RNG, time, command pattern, and build version helpers) plus Addressables-based asset loading and importing tooling (absorbed from com.gamelovers.assetsimporter v0.5.2 in v2.0.0).
Audience: This file is for contributors/agents working on the package. For user-facing docs, see README.md (quick start, per-service examples) and docs/ (full API reference).
flowchart TD
MainInstaller["MainInstaller (static)"] -->|"wraps"| Installer
Installer -->|"Bind/Resolve"| Services
subgraph Services ["Bound Services"]
MessageBroker["MessageBrokerService"]
TickService["TickService"]
CoroutineService["CoroutineService"]
DataService["DataService"]
TimeService["TimeService"]
RngService["RngService"]
PoolService["PoolService"]
CommandService["CommandService<TGameLogic>"]
AssetResolver["AssetResolverService"]
end
TickService -->|"DontDestroyOnLoad host"| TickMono["TickServiceMonoBehaviour"]
CoroutineService -->|"DontDestroyOnLoad host"| CoroutineMono["CoroutineServiceMonoBehaviour"]
CommandService -->|"uses"| MessageBroker
AssetResolver -->|"extends"| AddressablesLoader["AddressablesAssetLoader\n(IAssetLoader + ISceneLoader)"]
| Interface | Namespace | Implementation | File |
|---|---|---|---|
IInstaller |
GameLovers.Services |
Installer |
Runtime/DependencyInjection/Installer.cs |
IMessageBrokerService |
GameLovers.Services |
MessageBrokerService |
Runtime/MessageBrokerService.cs |
ITickService |
GameLovers.Services |
TickService |
Runtime/TickService.cs |
ICoroutineService |
GameLovers.Services |
CoroutineService |
Runtime/CoroutineService.cs |
IPoolService |
GameLovers.Services.Pooling |
PoolService (ns GameLovers.Services) |
Runtime/Pooling/IPoolService.cs, Runtime/PoolService.cs |
IObjectPool<T> |
GameLovers.Services.Pooling |
ObjectPool<T>, GameObjectPool, GameObjectPool<T> |
Runtime/Pooling/ |
IDataProvider / IDataService |
GameLovers.Services |
DataService |
Runtime/DataService.cs |
ITimeService / ITimeManipulator |
GameLovers.Services |
TimeService |
Runtime/TimeService.cs |
IRngService |
GameLovers.Services |
RngService |
Runtime/RngService.cs |
ICommandService<TGameLogic> |
GameLovers.Services.Commands |
CommandService<TGameLogic> (ns GameLovers.Services) |
Runtime/Commands/ICommandService.cs, Runtime/CommandService.cs |
IGameCommand<TGameLogic> / IGameServerCommand<TGameLogic> |
GameLovers.Services.Commands |
(user-defined commands) | Runtime/Commands/IGameCommand.cs |
IAssetLoader |
GameLovers.Services.AssetsImporter |
AddressablesAssetLoader |
Runtime/AssetsImporter/AddressablesAssetLoader.cs |
ISceneLoader |
GameLovers.Services.AssetsImporter |
AddressablesAssetLoader |
Runtime/AssetsImporter/AddressablesAssetLoader.cs |
IAssetResolverService / IAssetAdderService |
GameLovers.Services |
AssetResolverService |
Runtime/AssetResolverService.cs |
Runtime/DependencyInjection/Installer.cs, Runtime/DependencyInjection/MainInstaller.cs
Installerstores interface type -> instance bindings;MainInstalleris a static global wrapper over one privateInstaller.- Binding is instance-based (
Bind<T>(T instance)), not type-to-type or lifetime-managed DI. - Only interfaces can be bound; non-interface binds throw
ArgumentException. Installersupports multi-interface binds (Bind<T,T1,T2>andBind<T,T1,T2,T3>).MainInstallerexposes only single-interfaceBind<T>.- Re-binding the same interface throws (
Dictionary.Add); there is no overwrite behavior.
Runtime/MessageBrokerService.cs
- Message contract:
IMessage Publish<T>iterates subscribers directly; usePublishSafe<T>when handlers may subscribe/unsubscribe during publish (safe copy, extra allocation).Subscribe<T>stores subscribers byaction.Target; static method subscriptions throw.Unsubscribe<T>(null)clears all subscribers for that message type;UnsubscribeAll(null)clears everything.
Runtime/TickService.cs
- Creates a
DontDestroyOnLoadGameObject withTickServiceMonoBehaviourto drive Unity callbacks. - Subscriber APIs all take
Action<float>;Unsubscribe(action)removes from all Update/Fixed/Late lists. - Type-specific unsubscribe and bulk clear APIs exist for Update, FixedUpdate, and LateUpdate.
deltaTime > 0enables buffered ticking (rate-limited).timeOverflowToNextTickcarries overflow to reduce drift.realTime=trueusesTime.realtimeSinceStartup;false(default) usesTime.time.
Runtime/CoroutineService.cs
- Creates a
DontDestroyOnLoadGameObject withCoroutineServiceMonoBehaviour. StartCoroutine(IEnumerator)returns a plain UnityCoroutine; async variants returnIAsyncCoroutine/IAsyncCoroutine<T>with completion callbacks and state.- Delay-call argument order is action first, delay last:
StartDelayCall(Action call, float delay)andStartDelayCall<T>(Action<T> call, T data, float delay). StopCoroutine(Coroutine)andStopAllCoroutines()proxy through the host MonoBehaviour.
Runtime/PoolService.cs, Runtime/Pooling/ObjectPool.cs
- Pool registry:
PoolService : IPoolService— one pool per type. - Pool implementations:
ObjectPool<T>— generic; lifecycle hooks via direct cast (IPoolEntitySpawn,IPoolEntityDespawn)GameObjectPool—GameObjectpools; lifecycle hooks viaGetComponent<>(); managesSetActiveGameObjectPool<T> where T : Behaviour— component-typed; sameGetComponent<>()hook pattern
IObjectPool<T>covers spawn/despawn/reset/clear plusSampleEntityandSpawnedReadOnly; seedocs/pool-service.mdfor the full surface.- Lifecycle hook interfaces:
IPoolEntitySpawn,IPoolEntitySpawn<T>,IPoolEntityDespawn,IPoolEntityObject<T>. CallOnSpawned/CallOnDespawnedare virtual inObjectPoolBase<T>— override to customize lifecycle dispatch.
Runtime/DataService.cs
IDataProvideris read-only (GetData<T>(),HasData<T>());IDataServiceadds add/load/save methods.- In-memory store is keyed by
Type(not string). Only reference types (where T : class) are supported. - Disk persistence via
PlayerPrefs+Newtonsoft.Jsonserialization. Key =typeof(T).Name.
Runtime/TimeService.cs
ITimeServiceis read-only time access + conversion methods;ITimeManipulatoraddsAddTime(float)andSetInitialTime(DateTime).TimeServiceimplementsITimeManipulator. Bind asITimeManipulatorfor write access;ITimeServicefor read-only consumers.
Runtime/RngService.cs
RngData/IRngDatahold deterministic state (Seed,Count,State).IRngServiceexposes consuming (Next,Range) and non-consuming (Peek,PeekRange) APIs plusRestore(int count).RngService.CreateRngData(int seed)— static factory forRngData.- Float API uses
floatPfromcom.gamelovers.gamedata.
Runtime/CommandService.cs
- Command contract:
IGameCommand<TGameLogic>withvoid Execute(TGameLogic, IMessageBrokerService). - Server-only variant:
IGameServerCommand<TGameLogic>withvoid ExecuteLogic(TGameLogic). - Service:
ICommandService<TGameLogic>→CommandService<TGameLogic>(TGameLogic, IMessageBrokerService). CommandServiceexposesprotected TGameLogic GameLogicandprotected IMessageBrokerService MessageBrokerfor subclassing (added in v0.15.1).- Execution is synchronous. Use struct commands for fire-and-forget; class commands for reference semantics.
Runtime/VersionServices.cs
- Static class for
version-dataResources metadata.VersionExternalis always safe;VersionInternal,Branch,Commit, andBuildNumberrequire successfulLoadVersionDataAsync()first.
- Runtime:
Runtime/- Entry points:
Runtime/DependencyInjection/Installer.cs,Runtime/DependencyInjection/MainInstaller.cs - Foundation services (ns
GameLovers.Services):MessageBrokerService.cs,TickService.cs,CoroutineService.cs,PoolService.cs,DataService.cs,TimeService.cs,RngService.cs,VersionServices.cs,CommandService.cs,AssetResolverService.cs - Command contracts (ns
GameLovers.Services.Commands, inCommands/):IGameCommand.cs,ICommandService.cs - Pool contracts + implementations (ns
GameLovers.Services.Pooling, inPooling/):IPoolService.cs,IObjectPool.cs,IPoolEntity.cs,ObjectPool.cs,GameObjectPool.cs - Asset loading contracts + implementations (ns
GameLovers.Services.AssetsImporter, inAssetsImporter/):IAssetLoader.cs,ISceneLoader.cs,AddressablesAssetLoader.cs,AddressableConfig.cs,AssetConfigsScriptableObject.cs,AssetLoaderUtils.cs,AssetReferenceScene.cs
- Entry points:
- Editor:
Editor/— all code here is editor-only; do not reference from runtime assembliesEditor/Versioning/(nsGameLovers.Services.Versioning.Editor):VersionEditorUtils.cs,GitEditorProcess.cs,VersioningEditorSettings.cs(ScriptableSingleton →ProjectSettings/VersioningEditorSettings.asset),VersioningMenu.cs(Tools > GameLovers > Versioning/...stubs)Editor/AssetsImporter/(nsGameLovers.Services.AssetsImporter.Editor):AssetConfigsImporter.cs(public API, user code extends),AssetsImporterEditorSettings.cs(ScriptableSingleton →ProjectSettings/AssetsImporterEditorSettings.asset),AssetsImporterEditorUtils.cs(discovery + import logic),AssetsImporterMenu.cs(Tools > GameLovers > Assets Importer/...stubs)Editor/AddressableIds/(nsGameLovers.Services.AddressableIds.Editor):AddressableIdsEditorSettings.cs(ScriptableSingleton →ProjectSettings/AddressableIdsEditorSettings.asset, includesIsValidIdentifier/IsValidNamespacevalidators and a persisted last-generation snapshot — sorted address/label arrays + timestamp + filename/label-filter-used, written byRecordGenerationfrom insideAddressableIdsGeneratorUtils.Generate),AddressableIdsGeneratorUtils.cs(pure generation logic returningGenerationResult; also exposesDiffreturningDiffResultandComputeFreshnessreturningFreshnessResultfor the Explorer tab —Diffis on-demand only because it runs the fullGetAssetList+ProcessDatascan,ComputeFreshnessis file-stat-only and cheap),AddressableIdsMenu.cs(Tools > GameLovers > Addressable Ids/...stubs)Editor/Explorer/Windows/(nsGameLovers.Services.Editor.Explorer):ServicesExplorerWindow.cs(exposesSelectTab<T>()andOpenOnTab<T>()),ServicesExplorerWindow.uxml,ServicesExplorerWindow.ussEditor/Explorer/Tabs/(nsGameLovers.Services.Editor.Explorer.Tabs):ServiceTab.cs(abstract base; includesMakePrimaryButtonhelper) + 13 concrete tabs:OverviewTab(takesServicesExplorerWindowreference for tab-jumping),VersioningTab,InstallerTab,MessageBrokerTab,TickTab,CoroutineTab,PoolTab,DataTab,TimeTab,RngTab,AssetResolverTab,AssetsImporterTab,AddressableIdsTabEditor/Inspectors/andEditor/Scaffolders/: UIToolkit inspectors/property drawers andAssets > Create > GameLovers Services > ...scaffolders; templates live inEditor/Scaffolders/Templates~/- Assembly:
GameLovers.Services.Editor.asmdef
- Tests:
Tests/- Before reading, editing, or creating any file in
Tests/, you MUST readTests/AGENTS.mdfirst. EditMode/Unit/covers non-MonoBehaviour services plus AssetResolver/AssetLoaderUtils;EditMode/Performance/covers ObjectPool and MessageBroker.PlayMode/Unit/covers Unity-hosted services/pools;PlayMode/Integration/includes ServiceLifecycle, VersionServices, and explicit Addressables tests; PlayMode also has performance and smoke tests.
- Before reading, editing, or creating any file in
- Samples:
Samples~/— importable via Unity Package Manager. Each sample ships as a complete runnable Unity scene with hand-authored deterministic.cs.meta/.unity.metaGUIDs (matching thePackages/com.gamelovers.uiservice/Samples~/*pattern); the UI inside each sample is built programmatically at runtime viaUnityEngine.UI(legacyText, no TMP). SeeSamples~/README.mdfor the index, common-mistakes section sourced from §4 below, and the canonical list of sample-only types.Samples~/ServicesPlayground/— zero-Addressables scene exercising 10 of 13 Services Explorer tabs; usesBullet(pooled MonoBehaviour),PlayerData(POCO),TestMessage/PlayerLevelledUpMessage(IMessagestructs),GameLogic,LevelUpCommand,ServicesBootstrap,ServicesPlaygroundUI— all under namespaceGameLovers.Services.Samples.ServicesPlayground. The pooledBullet"sample entity" is generated as a sphere primitive at runtime inServicesBootstrap.GetOrCreateBulletPrefab().Samples~/AssetResolver/— Addressables-required scene covering the remaining 3 Explorer tabs (Asset Resolver, Assets Importer, Addressable Ids). UsesSpriteId(enum),SpriteConfigs : AssetConfigsScriptableObject<SpriteId, Sprite>,AssetResolverExampledriver — all under namespaceGameLovers.Services.Samples.AssetResolver. Ships three placeholder PNGs inSprites/Hero.png/Coin.png/Enemy.pngplus an emptySpriteConfigs.asset. Editor automation inSamples~/AssetResolver/Editor/AssetResolverSampleSetup.cs(assemblyGameLovers.Services.Samples.AssetResolver.Editor, namespaceGameLovers.Services.Samples.AssetResolver.Editor) auto-marks the sprites Addressable in a dedicated groupGameLoversServicesSamples_AssetResolver, applies the Addressables labelservices-sample-asset-resolver(registered viasettings.AddLabeland applied per-entry viaentry.SetLabel(force: true)), renames non-canonical filenames toHero/Coin/Enemy(substring-match first, alphabetical fallback), and wiresSpriteConfigs.assetrows. Triggered byAssetResolverSampleAssetPostprocessor.OnPostprocessAllAssetswhen paths under/Asset Resolver/Sprites/change, plusTools > GameLovers > Samples > Asset Resolver > Refresh Addressablesand a sample-scoped inspector button on the package'sAssetConfigsScriptableObjectEditor(visible only when the inspected asset path ends with/Asset Resolver/SpriteConfigs.asset). The group + label are NOT auto-removed when the sample is deleted (see "Sample-removal cannot self-cleanup" gotcha in §4); the per-sample README documents the manual cleanup steps as the user's undo. The inspector button invokes the menu viaEditorApplication.ExecuteMenuItemso the package editor assembly stays decoupled from the sample editor assembly. The sample also shipsSpriteConfigsImporter : AssetsConfigsImporter<SpriteId, Sprite, SpriteConfigs>(empty subclass) soAssetsImporterEditorUtils.DiscoverImportersreflection-scan surfaces a sample row in the Assets Importer tab.AssetResolverExample.Start()callsMainInstaller.Bind<IAssetResolverService>(...)andOnDestroy()callsMainInstaller.Clean()— this is what populates the Asset Resolver tab's liveAssetMaptree. A second sample-scoped menuTools > GameLovers > Samples > Asset Resolver > Open in Exploreropens the Services Explorer focused onAssetResolverTab; the sample's runtime UI's "Open Services Explorer" button calls it viaEditorApplication.ExecuteMenuItem(under#if UNITY_EDITOR) so the runtime assembly never references the package editor assembly. The sample's runtime files compile into a dedicatedGameLovers.Services.Samples.AssetResolver.asmdef(NOT into the project's defaultAssembly-CSharplikeSamples~/ServicesPlayground/does) — this is required so the sample editor assembly canusingthe sample's runtime types (SpriteId,SpriteConfigs) when declaring the concreteSpriteConfigsImporter. Asmdef-defined assemblies cannot referenceAssembly-CSharp, so the moment a sample needs editor↔runtime type sharing it MUST have its own runtime asmdef. The sample editor asmdef (GameLovers.Services.Samples.AssetResolver.Editor) referencesGameLovers.Services(runtime),GameLovers.Services.Editor(for the importer base type and theServicesExplorerWindow.OpenOnTab<...>call),GameLovers.Services.Samples.AssetResolver(the new sample runtime asmdef), andGameLovers.GameData(transitively required forPair<TId, AssetReference>exposed byAssetsConfigsImporterBase.OnImportIds). The runtime asmdef referencesGameLovers.Services,GameLovers.GameData(forPair<TKey,TValue>returned byAssetConfigsScriptableObject.Configs), plus the engine packages the sample's runtime code uses (Unity.TextMeshPro,UnityEngine.UI,UniTask,Unity.Addressables,Unity.ResourceManager,Unity.InputSystem); when the sample is moved into a host project atAssets/Samples/.../Asset Resolver/, the asmdef boundary is preserved and the prefab continues to resolveAssetResolverExamplevia its.cs.metaGUID even though the assembly name inm_EditorClassIdentifiershifts fromAssembly-CSharpto the new asmdef name on first import.
With "rootNamespace": "GameLovers.Services" on the asmdef, Unity's Create > C# Script auto-derives namespaces from folder paths. That is already correct for all subfolders except DependencyInjection/.
| Folder | Namespace | Notes |
|---|---|---|
Runtime/ (root) |
GameLovers.Services |
Concrete *Service classes + AssetResolverService |
Runtime/DependencyInjection/ |
GameLovers.Services |
Carve-out — new files here need manual namespace fix (strip DependencyInjection segment) |
Runtime/Commands/ |
GameLovers.Services.Commands |
Command contracts (interfaces only) |
Runtime/Pooling/ |
GameLovers.Services.Pooling |
Pool contracts + pool implementations |
Runtime/AssetsImporter/ |
GameLovers.Services.AssetsImporter |
Asset loading interfaces + Addressables loader |
The concrete PoolService stays in Runtime/ root under GameLovers.Services but references types from GameLovers.Services.Pooling — the file declares using GameLovers.Services.Pooling; at the top. CommandService follows the same pattern with using GameLovers.Services.Commands;.
MainInstallerexposes only single-interfaceBind<T>. Multi-interfaceBind<T, T1, T2>is onIInstaller/Installerdirectly.- There is no
MainInstaller.Instance— it is a static class wrapping a privateInstaller.
Publish<T>iterates subscribers directly; callingSubscribe/Unsubscribeduring publish throws.- Use
PublishSafe<T>if handlers may subscribe/unsubscribe during message handling (copies delegates first, at allocation cost). Subscribeusesaction.Targetas key — static method subscriptions throwArgumentException.
TickServiceandCoroutineServiceeach create aDontDestroyOnLoadGameObject. CallDispose()to tear them down (tests, game reset, domain reload).- These services do not enforce a singleton; constructing multiple instances creates multiple host GameObjects.
StopCoroutine(triggerOnComplete)honors its parameter as of v2.0.0:trueinvokes the registeredOnCompletecallback,falsesuppresses it. After either path the coroutine flips toIsCompleted == true/IsRunning == false, and the call is a no-op if it had already completed.- The Services Explorer's editor-only
_activeAsyncCoroutinestracking subscribes to a separate internalInternalCleanupevent onAsyncCoroutine(NOT to the publicOnCompletesetter). Do NOT route tracking throughOnComplete(...)— the public setter has replace semantics and would silently overwrite (or be overwritten by) user callbacks. Any future Coroutine-tab introspection should hookInternalCleanupinstead.
- Keys in
PlayerPrefsaretypeof(T).Name— name collisions are possible across assemblies with types sharing the same name. LoadData<T>usesActivator.CreateInstance<T>()when no saved data exists;Tmust have a parameterless constructor.- Only reference types (
class) are supported; value types (struct) are not.
PoolServicekeeps one pool per type;AddPool<T>()will throw (Dictionary.Add) if the type is already registered.GameObjectPool.Dispose(bool disposeSampleEntity)destroys theSampleEntityGameObject whentrue.GameObjectPool.Dispose()destroys all pooled instances but not the sample reference.GameObjectPool/GameObjectPool<T>useGetComponent<>()for lifecycle hooks on components.ObjectPool<T>casts the entity directly. This determines whereIPoolEntitySpawnetc. must be implemented.- External destruction resilience:
GameObjectPool.Dispose()andGameObjectPool<T>.Dispose()skip pooled entries whose underlyingGameObjectwas destroyed by an external owner (e.g. a parent GameObject was destroyed while pooled instances were still reparented under it viaDespawnToSampleParent). Any new code path that iterates pool internals (Stack<T>/SpawnedEntities/Clear()output) and dereferences entities or.gameObjectonBehaviourentries MUST use the same Unity fake-null guard (if (obj == null) continue;) thatSpawnEntityalready uses. Without the guard, dereferencing.gameObjecton a destroyedBehaviourthrowsMissingReferenceException.
UnloadAsset<T>is synchronous and returnsvoid. It only decrements the Addressables reference count viaAddressables.Release(asset)and invokesonCompleteCallback. The method was renamed fromUnloadAssetAsyncand its return type changed fromUniTasktovoidin v2.0.0 to accurately reflect its behaviour. Memory reclamation (Resources.UnloadUnusedAssets()) is the caller's responsibility at appropriate moments (scene transitions, boot, memory-pressure events). Do NOT addGC.Collect()/Resources.UnloadUnusedAssets()back into per-asset unload paths — they were removed in v2.0.0 because they caused PlayMode Test Runner crashes and O(total-assets-in-memory) stalls per call. Note: for prefab instances returned byInstantiateAsync,UnloadAssetdoes not destroy theGameObject; callers mustObject.Destroythe instance separately.AddressablesAssetLoaderimplements bothIAssetLoaderandISceneLoader.AssetResolverServiceextends it and sits in the rootGameLovers.Servicesnamespace while its dependencies live inGameLovers.Services.AssetsImporter.AssetResolverService.RequestAssetandLoadSceneAsync<TId>require assets to be pre-registered viaAddConfigs/AddAssets/AddAsset(throwsMissingMemberExceptionotherwise).AssetConfigsScriptableObject<TId,TAsset>inheritsAssetConfigsScriptableObjectBase<TId, AssetReference>(not<TId, TAsset>). The genericTAssetis captured only asAssetType. This is intentional for the Addressables weak-link pattern.IAssetAdderService.AddConfigs<TId, TAsset>is a C# 8 default interface method. It is defined inline in the interface body (void AddConfigs<TId, TAsset>(...) => AddAssets(configs.AssetType, configs.Configs);) and is NOT overridden inAssetResolverService. C# only dispatches default interface methods through the interface — calling_resolverService.AddConfigs(...)on a field typedAssetResolverServiceproducesCS1061: 'AssetResolverService' does not contain a definition for 'AddConfigs'. Type the field asIAssetAdderService(or cast at the call site) before callingAddConfigs. The same rule applies to any future default interface methods added toIAssetAdderService,IAssetResolverService, or any other interface in this package.
- The
TIdtype parameter onAssetsConfigsImporter<TId,TAsset,TScriptableObject>must satisfywhere TId : Enum. Passing a non-enum identifier type will not compile. - Editor-heavy methods (
AssetsConfigsImporter.Import,AddressableIdsGeneratorUtils.Generate,AddressableIdsGeneratorUtils.Diff) are intentionally not covered by automated tests — they requireAssetDatabaseaccess and are validated manually viaTools > GameLovers > Assets Importer / Import Assets Data,Tools > GameLovers > Addressable Ids / Generate Addressable Ids, or the Services Explorer tabs. - Settings for both tools are persisted via
ScriptableSingletoninProjectSettings/(notAssets/*.asset):AssetsImporterEditorSettings.assetandAddressableIdsEditorSettings.asset. - Addressable Ids last-generation snapshot:
AddressableIdsEditorSettings.assetstores the sorted address/label set, timestamp, and filename/label-filter that were used by the last successfulGenerate()call. The Services Explorer tab uses this snapshot for the "Check for stale Ids" diff. The asset is project-shared (lives underProjectSettings/) — commit it to VCS so all contributors see the same diff baseline; do not add it to.gitignore. The snapshot is rewritten on everyGenerate()call, so no separate maintenance is required. - Cost separation in
AddressableIdsTab: the per-tickRefresh()path is intentionally restricted toComputeFreshness(~20 file-stats) + snapshot read-back (in-memory). The "Check for stale Ids" button is the only call site forDiff, which runs the fullGetAssetList+ProcessDatapipeline (proportional to total Addressable entries). Do not move the diff intoRefresh()"to make it live" — the design choice is on-demand-only by intent. If a future change adds an event-driven invalidation, prefer wiringAddressableAssetSettings.OnModificationover polling.
CommandService<TGameLogic>hasprotected TGameLogic GameLogicandprotected IMessageBrokerService MessageBrokeraccessible in subclasses.ExecuteCommandis not declaredvirtual; to intercept execution, subclass and shadow withnew, or implementICommandService<TGameLogic>directly.
- Editor settings classes that extend
ScriptableSingleton<T>and use[SerializeField]fields must includeusing UnityEngine;.SerializeFieldAttributelives inUnityEngine, notUnityEditor— the compiler will reportCS0246if onlyusing UnityEditor;is present.
ServicesExplorerWindow.SelectTab<T>()andServicesExplorerWindow.OpenOnTab<T>()are constrainedwhere T : ServiceTab. Cards, inspector buttons, and menu stubs that navigate to a tab must pass tab types (e.g.AssetsImporterTab,AddressableIdsTab,VersioningTab), not service interface types (IAssetResolverService,IDataService, etc.). Do not relax the constraint towhere T : class— it exists specifically so the_tabslist lookup is strongly typed.OverviewTabholds aServicesExplorerWindowreference injected via its constructor (new OverviewTab(this)inRegisterTabs()). Each card'sOpenbutton calls_window.SelectTab<TTab>(). New cards follow the same pattern — add aBuildXCard()method that returns aVisualElementand registers the tab type at the call site.
ServiceTab.OnPlayModeChanged(ExitingPlayMode)does THREE things in this order: (1) callOnExitingPlayMode()synchronously to clear populated UI widgets, (2) callUpdateBannerVisibility(), (3) schedule a deferredRefresh()viaEditorApplication.delayCall. The defer in step 3 is required so the refresh runs AFTER scene teardown (i.e., after consumerMonoBehaviour.OnDestroy → MainInstaller.Clean()); steps 1+2 give the user instant visual feedback that the session ended.- The
EnteredEditModeevent issues anotherRefresh()as belt-and-braces in casedelayCallwas wiped by a domain reload. Together these guarantee that tabs surfacing populated state flip to their empty state immediately on Stop instead of holding a frozen last-play snapshot. ServiceTab.OnExitingPlayMode()virtual — populated-state tabs (InstallerTab,MessageBrokerTab,DataTab,RngTab,PoolTab,CoroutineTab,TickTab) override this to forcibly clear their lists/labels synchronously when Stop is pressed. Combined with a!EditorApplication.isPlayingshort-circuit at the top of eachRefresh(), this DECOUPLES the tab's empty state fromMainInstaller/*Servicestatic lifetime. Even if the consumer's bootstrap forgets to callMainInstaller.Clean()(or skipsTryCleanDisposeforIPoolService/ICoroutineService/ITickService) inOnDestroy, the tabs stay clean in edit mode. Do not collapse the override + short-circuit into either pattern alone — the override handles the synchronous transition tick (before scene teardown), the short-circuit handles all subsequent edit-mode refreshes. Any new tab that surfaces populated runtime state MUST add both pieces.- The
tab-bannertext is context-sensitive: it shows"Not in Play mode — showing last snapshot"only before the first play session of the editor session and"Play session ended — services unbound"thereafter. The_hasSeenPlaylatch is per-tab-instance (reset on domain reload, not persisted). Do not re-introduce a single static banner string without re-evaluating the cleanup-perception bug it fixed.
- Tabs whose
Refresh()rebuilds the hierarchy from scratch (AssetResolverTab,MessageBrokerTab, and any future tab that calls_tree.Clear()then re-createsFoldouts) MUST useServiceTab.MakeStickyFoldout(key, text, defaultExpanded)instead ofnew Foldout { text = ..., value = true }. Without it, the 250msRefreshIntervalMstimer creates freshFoldoutinstances every tick, which default to expanded (value = true) — collapse appears broken to the user because every refresh silently re-opens what they just closed. The sticky helper persists user-collapsed state in a per-tabHashSet<string>keyed by a stable identity (e.g.,messageType.FullName,assetType.FullName,assetType.FullName + "/" + idType.FullName). State lives only as long as the tab instance — domain reload resets it, which is fine for an editor-only diagnostic surface. Tabs whose foldouts are created ONCE inBuildUi()and only have their contents rebuilt inRefresh()(e.g.,TickTab's three top-level Update / FixedUpdate / LateUpdate foldouts) do NOT need the sticky helper — the foldout instance itself survives the refresh. The trap is specifically the rebuild-foldout-per-data-row pattern. - Nested-foldout
ChangeEvent<bool>bubbling:MakeStickyFoldout's value-changed callback filters withif (evt.target != foldout) return;. This filter is REQUIRED — UIToolkitChangeEvent<bool>bubbles up the visual tree, so a user click on an inner foldout'sTogglepropagates as a ChangeEvent through every ancestorFoldoutand would otherwise reach the outer foldout's callback (target = the inner toggle, not the outer foldout). Without the filter, collapsing an inner foldout silently marks the OUTER foldout collapsed too in the keys set, and the next periodic refresh re-renders both as collapsed — the visible bug is "the inner foldout's chevron collapses everything". Do not relax this filter when adding new ancestor-aware foldout interactions; if you need to react to a descendant's value change, register a separateRegisterCallback<ChangeEvent<bool>>with explicit target/source checks rather than relaxing the filter insideMakeStickyFoldout. - Rapid-click resilience via digest short-circuit:
ServiceTab.TryShortCircuitRefresh(string digest)lets a tab return early fromRefresh()when the displayed data is unchanged. This is required for tabs with rebuild-the-tree refresh (AssetResolverTab,MessageBrokerTab). Without the short-circuit, the every-250-ms timer destroys mouse-capturedVisualElements mid-click and Unity loses the mouse-up — rapid foldout clicks appear lost because the click was never recorded against any live target. The digest must capture every piece of state the rebuild path conditions on (e.g. forAssetResolverTabthat includes the_destructiveToggle.valueflag, since it gates per-row Unload buttons; the toggle wiresRegisterValueChangedCallback(_ => InvalidateRefreshDigest())so flipping it forces the next refresh to rebuild). Action paths that mutate the displayed data don't need explicit invalidation — the data change naturally produces a different digest. Action paths that mutateVisualElements directly (e.g.MessageBrokerTab.OnExitingPlayModeclears_listoutside the rebuild) should rely on the base class invalidating the digest on play-mode transitions (OnAttach,EnteredPlayMode,ExitingPlayMode,EnteredEditMode, and the deferred exit refresh) — see theInvalidateRefreshDigest()calls inServiceTab. New rebuild-style tabs MUST add aComputeDigest(...)method and callif (TryShortCircuitRefresh(digest)) return;at the top ofRefresh(), otherwise the rapid-click lost-input bug returns.
- Use
MakePrimaryDangerButton(...)(notMakePrimaryButton) for any tab action-bar primary that removes or invalidates state —Stop All Coroutines,Unsubscribe All,Clean All, etc. The helper applies the.action-primary-dangerUSS class (red-tinted bg + border + bold light-pink text). The previous convention of using the plain blue.action-primaryfor destructive actions is gone. - The companion per-row
.row-btn-dangerclass follows the same tinted-bg + border + bold light-text pattern. Do NOT regress either class to text-color-only styling — red text on Unity's default mid-grey button background fails contrast in both Personal (light) and Professional (dark) editor skins.
- Runtime expects a Resources TextAsset named
version-data(VersionServices.VersionDataFilename). The filename is a runtimeconstand is not configurable. VersionEditorUtilswritesversion-data.txton every domain reload ([InitializeOnLoadMethod]) and can be invoked by build pipelines. It uses git CLI; failures are handled gracefully.- The write folder is configurable per-project via
VersioningEditorSettings.instance.ResourcesFolderPath(defaultAssets/Configs/Resources). Change it from the Versioning tab in the Services Explorer (browse + reset). The chosen folder must contain aResourcespath segment soResources.Load<TextAsset>("version-data")can locate the file at runtime. VersioningEditorSettingspersists toProjectSettings/VersioningEditorSettings.asset(editor-only, not committed to version control by default).VersionExternalis always safe (readsApplication.versiondirectly).VersionInternal,Branch,Commit, andBuildNumberthrowException("Version Data not loaded.")if data has not been loaded — callLoadVersionDataAsync()early in boot.
Runtime/AssemblyInfo.cs grants [assembly: InternalsVisibleTo("GameLovers.Services.Editor")].
Services expose minimal internal read-only accessors so the Services Explorer can display state without widening the public API:
Installer.Bindings,MainInstaller.InstallerInstance,MessageBrokerService.Subscriptions/IsPublishing- Tick lists (
OnUpdateList,OnFixedUpdateList,OnLateUpdateList) plus internalTickData;CoroutineService.ActiveAsyncCoroutinesunder#if UNITY_EDITOR PoolService.Pools,DataService.DataEntries,TimeService.ExtraTime/InitialTime,AssetResolverService.AssetMap
Rule: if you add a new service and want to surface it in the Explorer, add a new internal read-only accessor (no behavior change) and create a new ServiceTab subclass in Editor/Explorer/Tabs/. Do not add public accessors solely for editor introspection — use internal + InternalsVisibleTo.
Samples~/ServicesPlayground/MUST remain Addressables-free and Resources-load-free at runtime. The pooledBulletsample entity is generated programmatically (GameObject.CreatePrimitive(PrimitiveType.Sphere)inServicesBootstrap.GetOrCreateBulletPrefab());VersionServices.LoadVersionDataAsync()readsversion-data.txtthat is written automatically by the host project'sEditor/Versioning/VersionEditorUtilson every domain reload — the sample does not own that file.ServicesBootstrap.ApplyBulletMaterialColorMUST set both_BaseColor/_Color(diffuse tint) AND enable_EMISSIONwith an_EmissionColorfor the bullet material — the playground scene ships with no lights, and Lit shaders (URP / HDRP / Built-in Standard) render near-black under no lighting. The emission keyword +globalIlluminationFlags = Nonemake the sphere self-illuminate so it is visible regardless of pipeline. Do not strip the emission setup "for simplicity" — it is the single thing keeping the pool demo readable.ServicesPlaygroundUI.Coroutine_StartAsyncMUST use a wait long enough to span multiple Services Explorer refresh cycles (RefreshIntervalMs = 250onCoroutineTab). The current implementation usesWaitForSeconds(3f). Do not regress to shortWaitFrames(60)-style helpers — at editor framerates above ~60 fps the coroutine completes inside a single refresh cycle and the user sees no entry in the Coroutine tab, making the demo button look broken even though the runtime is correct.- The sample UI is shipped as a hand-authored prefab (
ServicesPlaygroundUI.prefab/AssetResolverUI.prefab). The runtime script holds[SerializeField]references to every Button + theTMP_Textlog/live-status panes and wiresonClick.AddListenerinAwake. The driver also callsEnsureInputModuleOnEventSystem()so the scene'sEventSystemcarries the right input module for the consumer's Active Input Handling setting (legacyStandaloneInputModulevsInputSystemUIInputModule, picked at runtime via#if ENABLE_INPUT_SYSTEM). UI text usesTextMeshProUGUIfrom thecom.unity.textmeshpropackage — consumers must import TMP Essentials once on first use. - Re-generating sample prefabs from code: prefabs are stored in source as fully serialized YAML, but they were originally generated by a one-shot
Assets/Editor/Tools/GenerateSamplePrefabs.csutility (deleted after first validation). If a future change requires a wholesale prefab rebuild (e.g. swapping uGUI for UI Toolkit, or restructuring the section grid), restore that utility — its menu items didPrefabUtility.SaveAsPrefabAsset+PrefabUtility.InstantiatePrefab+EditorSceneManager.SaveSceneand mirrored prefab/scene fromAssets/Samples/...toPackages/com.gamelovers.services/Samples~/...viaSystem.IO.File.Copy. The mirror direction matters:PrefabUtilitycan only write underAssets/; raw file copy is required to push the result back into the package source folder. - Do NOT migrate
ServicesPlaygroundto useAssetResolverServiceorAddressables.LoadAssetAsync"to consolidate samples". TheAssetResolversample is the explicit place for the Addressables setup story. - AssetResolver sample editor automation is sample-scoped, not package-wide.
Samples~/AssetResolver/Editor/AssetResolverSampleSetup.cslives inside the sample folder (assemblyGameLovers.Services.Samples.AssetResolver.Editor) so it is imported only when the user imports the sample, and removed when they remove it. The script DOES use[InitializeOnLoadMethod]as a safety net (UPM-import chicken-and-egg:OnPostprocessAllAssetsfires for the sample's sprites BEFORE the post-processor itself compiles in the very first import, so the post-processor would otherwise miss its own first invocation), but because the script lives in the sample folder, removing the sample also removes thisInitializeOnLoadhandler — there is no orphan handler running in consumer projects after sample deletion. The package's mainEditor/assembly does NOT reference the sample editor assembly; the inspector button inAssetConfigsScriptableObjectEditorinvokes the sample's menu item viaEditorApplication.ExecuteMenuItem("Tools/GameLovers/Samples/Asset Resolver/Refresh Addressables")to keep the boundary clean. Do not moveAssetResolverSampleSetupinto the mainEditor/assembly. The automation is idempotent — repeated runs are no-ops, suppress all logs when nothing changed (silentpath), and existing user mappings onSpriteConfigs.assetrows that point at a different sprite are NEVER overwritten (the wiring code skips the row whenm_AssetGUIDis non-empty and differs from the canonical sprite's GUID). - AssetResolver sample binds via
MainInstaller(matchingServicesPlayground).AssetResolverExample.Start()callsMainInstaller.Bind<IAssetResolverService>(_resolver)andOnDestroy()callsMainInstaller.Clean(). Without the bind, the Services Explorer Asset Resolver tab shows"IAssetResolverService not bound"while the sample is the only thing running (it falls back toTryResolve<IAssetResolverService>()againstMainInstaller). The field is typed as the concreteAssetResolverServicesoAddConfigs(a C# 8 default interface method onIAssetAdderService) can be called via an explicit cast — see also theIAssetAdderService.AddConfigsgotcha higher in §4. - AssetResolver sample-scoped Explorer-jump menu. The sample editor assembly registers
Tools > GameLovers > Samples > Asset Resolver > Open in Explorer(callingServicesExplorerWindow.OpenOnTab<AssetResolverTab>()). The sample's runtime UI's "Open Services Explorer" button invokes that menu viaEditorApplication.ExecuteMenuItemunder#if UNITY_EDITOR, so the sample's RUNTIME assembly never has to take a reference onGameLovers.Services.Editor. This is the documented pattern for runtime sample UI to cross into editor code (matchingAssetConfigsScriptableObjectEditor's sample-scoped refresh button). Do not collapse this indirection by giving the sample's runtime assembly a direct reference on the editor assembly — that breaks player builds (editor assemblies are not included). - AssetResolver sample applies the Addressables label
services-sample-asset-resolverto every sprite the post-processor marks Addressable (settings.AddLabel(LabelName)once +entry.SetLabel(LabelName, true, force: true)per entry). This is the single piece that lets the Addressable Ids tab demo a sample-scoped Generate against this sample without leaking into the user's other Addressables. The sample DOES NOT auto-mutateAddressableIdsEditorSettings(aScriptableSingletonpersisted in the user'sProjectSettings/) — the per-sample README walks the user through typing the three field values manually so the demo teaches the tab's UX rather than mutating user-owned state. Do not "improve" the sample by writing those settings on import. - Sample-removal cannot self-cleanup (this was tried, this is why it doesn't work). Unity Package Manager has no per-sample Remove button — sample uninstall happens when the user manually deletes the imported folder under
Assets/Samples/.../<Sample>/from the Project window. A natural-feeling fix would beAssetPostprocessor.OnPostprocessAllAssetswatchingdeletedAssetsfor the sample's editor script's own path, but that does NOT work: when a.csfile is deleted, Unity recompiles BEFORE firingOnPostprocessAllAssetsfor the deletion batch. The recompile drops the just-deleted script from the new assemblies, so the post-processor class itself no longer exists by the time the deletion-batch callback would fire — the callback never reaches a live class. The post-processor cannot watch for its own funeral because it's already in the casket. Therefore: any sample whose import path mutatesAddressableAssetSettings,ProjectSettings/-stored singletons, or any other project-state outsideAssets/Samples/.../<Sample>/MUST document the manual cleanup steps in its per-sample README. Do not attempt a self-cleanupOnPostprocessAllAssetsdeletion-watch — it's dead code dressed up as a fix. The two viable paths if cleanup is genuinely required are (a) move the cleanup logic into the package's mainEditor/assembly with hardcoded constants for the sample's group/label names + an[InitializeOnLoadMethod]orphan-detector that runs every domain reload (violates the sample-scoped boundary; runs in every consumer's project forever), or (b) accept the original "removing this group is the user's undo" design — for the AssetResolver sample, we chose (b). - Sample-only types live in
GameLovers.Services.Samples.<SampleName>namespaces specifically so contributors and AI assistants cannot mistake them for package public API. When updatingSamples~/README.mdor per-sample READMEs, never describe these types as part of the services API surface — UiService's master README has historical drift on this exact point (presenter-definedOnCloseRequestedevents surfaced as if they were API). - Sample meta-file policy:
Samples~/ServicesPlayground/*.cs.meta,*.unity.meta, etc. are hand-authored with deterministic GUIDs so the shipped.unityscene can reference scripts at commit time (matching the UiService pattern — seePackages/com.gamelovers.uiservice/Samples~/BasicUiFlow/BasicUiExamplePresenter.cs.meta). When adding a new.csor scene/prefab to a sample, pick a stable random GUID and write the.metayourself — do not let Unity generate a fresh random one, otherwise the scene'sm_Script: {fileID: 11500000, guid: ..., type: 3}references will break for every fresh import. Folder.metafiles insideSamples~/are NOT required (Unity treatsSamples~as a non-asset folder); only file-level.metaare needed.
Installer.Bind<T>throwsArgumentExceptionfor non-interfaces or duplicate bindings;MainInstaller.Resolve<T>throwsKeyNotFoundExceptionwhen missing.MessageBrokerService.Subscriberejects static methods; directPublish<T>can throw if the subscription list is mutated during dispatch.DataService.GetData<T>throws when missing;LoadData<T>requires a parameterless constructor.- Duplicate
PoolService.AddPool<T>calls throw;AssetResolverServicerequests throwMissingMemberExceptionuntil assets/scenes are registered. VersionInternal,Branch,Commit, andBuildNumberthrowException("Version Data not loaded.")until version data loads successfully.
- C#: C# 9.0 syntax; explicit namespaces; no global usings.
- Assemblies
- Runtime must not reference
UnityEditor. - Editor tooling must live under
Editor/(or be guarded with#if UNITY_EDITORif absolutely necessary).
- Runtime must not reference
- Performance
- Be mindful of allocations in hot paths (e.g.,
PublishSafeallocates; tick lists mutate; avoid per-frame allocations).
- Be mindful of allocations in hot paths (e.g.,
Prefer local UPM cache / local packages when needed:
- GameData (
floatP,MathfloatP):Packages/com.gamelovers.gamedata/ - Unity Newtonsoft JSON: check
Library/PackageCache/if you need source details - Unity Addressables API:
Library/PackageCache/com.unity.addressables@<version>/ - UniTask API:
Library/PackageCache/com.cysharp.unitask@<version>/
- Add a service: add runtime interface + implementation under
Runtime/, keep UnityEngine usage minimal, add/adjust tests, and use theTickService/CoroutineServicehost pattern for Unity callbacks. - Add a command: implement
IGameCommand<TGameLogic>withvoid Execute(TGameLogic, IMessageBrokerService)and add unit coverage inTests/EditMode/Unit/CommandServiceTest.cs. - Update versioning: ensure
version-data.txtstill lands under a Resources folder and update both runtime parsing andVersionEditorUtilswriting whenVersionServices.VersionDatachanges.
Update this file when:
- Binding/service-locator APIs, core service behavior, asset loading/import, or versioning behavior changes
- Dependencies, package layout, namespace mapping, or external type requirements change
- New services, editor tabs, inspectors, scaffolders, or internal Explorer accessors are added
- Menu paths under
Tools/GameLovers/...change (update §3 Editor folder map and §4 gotcha) - New
ScriptableSingletonsettings files are added or their[FilePath]changes (update §3) AddressableIdsEditorSettingsorAssetsImporterEditorSettingsvalidators change (update §4)- Sample folder structure, sample-only types, or per-sample setup requirements change → update
Samples~/README.md, the matching per-sampleREADME.md, thesamples[]block inpackage.json, AND theSamplesrow in §3 above. Adding a new sample requires all four edits in lockstep to avoid the README-vs-source drift documented inSamples~/README.md.