Skip to content

Repository files navigation

CoreKit

A core utility framework for Unity: an event bus, a service locator, a unified tick loop, and a state-locking primitive. Allocation-conscious, dependency-free, and designed for production game code.

English | Türkçe


English

Project Overview

Most Unity projects re-implement the same four pieces of infrastructure: a way for systems to communicate without referencing each other, a way to locate shared services, a way to gate a system's execution while it is busy, and a controlled update order. Done ad hoc, these turn into hard dependencies, scattered FindObjectOfType calls, non-deterministic Update ordering, and per-frame garbage.

CoreKit consolidates those four concerns into small, independent modules. The design priorities are concrete and measurable rather than stylistic:

  • No per-frame allocation on hot paths. Event dispatch, ticking, lock checks, and service resolution allocate nothing during steady-state gameplay, which keeps GC pressure flat on mobile and console.
  • Decoupled systems. Modules communicate through value-type events and an explicit service registry, so gameplay systems do not hold direct references to one another.
  • Deterministic execution. A single update loop with explicit priority replaces a sprawl of independent MonoBehaviour.Update callbacks, making frame behavior reproducible.
  • No dependencies. Pure C# against the Unity API; nothing to resolve, version, or conflict with.

Each module is usable on its own. Adopt one without buying into the others.

Memory Profile & Allocation Guarantees

CoreKit's allocation claims are scoped precisely, so you know exactly what the "0 B" guarantee covers and what it does not.

  • Steady-state runtime vs. setup/teardown. CoreKit guarantees exactly 0 bytes of heap allocation during steady-state runtime — firing events, executing ticks, looking up services, and checking locks all run garbage-free, frame after frame. One-time dynamic allocations are permitted only during the setup/initialization phase (e.g. Subscribe(), Register(), or Lock() expanding an internal collection's capacity) and during teardown (e.g. ServiceProvider.DisposeAll() allocating a cleanup snapshot). These are bounded, happen off the hot path, and never recur once the game loop is warm.
  • Exceptional paths. Error/exceptional paths will allocate — dynamic strings inside thrown exceptions, or caught Debug.LogError triggers for a missing service lookup or a faulting handler. This is expected and deliberate: it only runs when something is already wrong, and never touches the steady-state game loop.
  • Pre-sized compaction caches. During reentrant unsubscriptions (a subscriber removing itself mid-dispatch), the bus tracks the affected handler lists in a dedicated internal collection (_pendingSweep) so it can compact them after the outermost dispatch unwinds. To keep this off the hot path, that sweep cache is aggressively pre-sized, so it does not dynamically resize its capacity during dispatch — even the reentrant-unsubscribe path stays allocation-free.

Installation

Add via the Unity Package Manager using a Git URL:

https://github.com/acharad/corekit.git

Or pin a version in Packages/manifest.json:

{
  "dependencies": {
    "com.acharad.corekit": "https://github.com/acharad/corekit.git#1.0.0"
  }
}

Components Breakdown

Events — structural pub/sub via generic constraints

The EventBus dispatches value-type events. The public API is constrained with where T : struct, IEvent and is generic end to end, so the payload is never widened to object and is therefore never boxed. Fire<T>(in T) passes the event by readonly reference, avoiding a defensive copy even for large structs. Handlers are stored as typed Action<T> delegates, so invocation is a single cast with no reflection. Dispatch is FIFO and reentrancy-safe: removing a subscriber mid-dispatch tombstones its slot and compacts the list after the outermost dispatch completes, so the bus does not allocate a snapshot per fire. It is single-threaded by design and targets the Unity main thread.

Locking — reference-counted state gating

ServiceLocker answers one question: should this system run right now? It is reference-counted by distinct reason string and stays locked while any reason is active. Lock("cutscene") and Lock("loading") engage two independent holds; the locker reports IsLocked until both are released. This avoids the common bug where two unrelated systems both try to pause and one prematurely re-enables the other. IsLocked is a single count check.

Services — tag-based service locator

ServiceProvider is a static registry keyed by type plus an optional tag, so the same interface can resolve to several distinct implementations (AudioService vs. AudioService tagged "music"). The key is a readonly struct with hand-written equality, so resolution is an allocation-free O(1) dictionary lookup. Everything registered implements IService (which extends IDisposable), so DisposeAll() can tear the entire graph down uniformly at scene or session end.

Ticking — unified execution loop

TickService replaces N independent MonoBehaviour.Update callbacks with one managed loop you drive from a single place. Services register once and are kept sorted by TickPriority via binary-search insertion, so execution order is explicit and deterministic frame to frame. The loop walks the list by index (no enumerator allocation) and skips any service whose ServiceLocker is engaged, so pausing a system costs an O(1) check and no garbage. The result is one native↔managed boundary crossing instead of dozens, with ordering you control.

Quick Start

The modules are designed to compose. HealthService is resolvable through the locator (Services) and reacts to events (Events); a separate RegenService is the part that actually ticks (Ticking) and is suspended during cutscenes (Locking). The event is raised by a dedicated DamageSource — a bootstrap's job is to wire systems together, not to deal damage.

using UnityEngine;
using CoreKit.Core.Events;
using CoreKit.Core.Services;

// Value-type event: dispatched without boxing or heap allocation.
public readonly struct PlayerDamaged : IEvent
{
    public readonly int Amount;
    public PlayerDamaged(int amount) => Amount = amount;
}

// Events + Services: reacts to events and is resolvable through the locator.
public interface IHealthService : IService
{
    int Health { get; }
    void Heal(int amount);
}

public sealed class HealthService : IHealthService
{
    private readonly EventBus _bus;
    private int _health = 100;

    public int Health => _health;

    public HealthService(EventBus bus) => _bus = bus;

    public void Init()
    {
        ServiceProvider.Register<IHealthService>(this); // register in the locator
        _bus.Subscribe<PlayerDamaged>(OnDamaged);       // subscribe to events
    }

    public void Heal(int amount) => _health = Mathf.Min(100, _health + amount);

    private void OnDamaged(PlayerDamaged e)
    {
        _health -= e.Amount;
        Debug.Log($"Took {e.Amount} damage -> {_health} HP");
    }

    public void Dispose() => _bus.Unsubscribe<PlayerDamaged>(OnDamaged);
}

RegenService is the part that genuinely ticks — it regenerates health each frame and is skipped automatically while its Locker is engaged:

using CoreKit.Core.Services;
using CoreKit.Core.Locking;
using CoreKit.Core.Ticking;

// Ticking + Locking: regenerates health each frame, skipped while locked (e.g. cutscenes).
public sealed class RegenService : ITickableService
{
    public int TickPriority => 0;                 // ascending order; lower ticks earlier
    public ServiceLocker Locker { get; } = new(); // skipped by the loop while engaged

    private IHealthService _health;

    public void Init(TickService ticker)
    {
        ServiceProvider.Register(this); // register in the locator
        ticker.Register(this);          // join the update loop
    }

    public void Tick(float deltaTime)
    {
        _health ??= ServiceProvider.Get<IHealthService>();
        _health.Heal(1); // not called while Locker.IsLocked
    }

    public void Dispose() { }
}

Damage comes from whatever actually deals it — a trap, an enemy, an ability — never from the bootstrap:

using CoreKit.Core.Events;

// Raises the event. Lives with the system that deals damage, not the composition root.
public sealed class DamageSource
{
    private readonly EventBus _bus;
    public DamageSource(EventBus bus) => _bus = bus;

    public void Hit(int amount) => _bus.Fire(new PlayerDamaged(amount));
}

Wire it all together from a single composition root:

using UnityEngine;
using CoreKit.Core.Events;
using CoreKit.Core.Services;
using CoreKit.Core.Ticking;

public sealed class GameBootstrap : MonoBehaviour
{
    private readonly EventBus _bus = new();
    private readonly TickService _ticker = new();

    private void Awake()
    {
        new HealthService(_bus).Init();   // Events + Services
        new RegenService().Init(_ticker); // Ticking + Locking
    }

    private void Update() => _ticker.Tick(Time.deltaTime); // one loop drives every service

    private void OnDestroy()
    {
        ServiceProvider.DisposeAll(); // dispose every registered IService
        _bus.Dispose();
    }
}

License

MIT © Ahmet İmran Kavraş


Türkçe

Proje Genel Bakış

Çoğu Unity projesi aynı dört altyapı parçasını yeniden yazar: sistemlerin birbirine referans vermeden haberleşmesi, paylaşılan servislerin bulunması, bir sistem meşgulken çalışmasının geçici olarak durdurulması ve kontrollü bir güncelleme sırası. Plansız yapıldığında bunlar sıkı bağımlılıklara, dağınık FindObjectOfType çağrılarına, deterministik olmayan Update sırasına ve kare başına çöp (garbage) üretimine dönüşür.

CoreKit bu dört sorumluluğu küçük ve birbirinden bağımsız modüllerde toplar. Tasarım öncelikleri stilistik değil, somut ve ölçülebilirdir:

  • Sıcak yollarda kare başına bellek tahsisi (allocation) yok. Olay gönderimi, ticking, kilit kontrolleri ve servis çözümlemesi normal oynanış sırasında hiçbir tahsis yapmaz; bu da mobil ve konsolda çöp toplayıcı (GC) baskısını sabit tutar.
  • Gevşek bağlı (decoupled) sistemler. Modüller değer tipli olaylar ve açık bir servis kaydı üzerinden haberleşir; oynanış sistemleri birbirine doğrudan referans tutmaz.
  • Deterministik yürütme. Açık öncelikli tek bir güncelleme döngüsü, birbirinden bağımsız çok sayıda MonoBehaviour.Update çağrısının yerini alır ve kare davranışını tekrarlanabilir kılar.
  • Bağımlılık yok. Unity API'si üzerine saf C#; çözümlenecek, sürümlenecek veya çakışacak hiçbir şey yok.

Her modül tek başına kullanılabilir. Diğerlerini benimsemeden yalnızca birini alabilirsiniz.

Bellek Profili ve Tahsis Garantileri

CoreKit'in allocation iddiaları net bir kapsamda verilir; böylece "0 B" garantisinin neyi kapsayıp neyi kapsamadığını tam olarak bilirsiniz.

  • Steady-state runtime vs. setup/teardown. CoreKit, steady-state runtime boyunca tam olarak 0 byte heap allocation garantisi verir — event fire etmek, tick'leri çalıştırmak, servis resolve etmek ve kilit kontrolü yapmak kare kare garbage üretmeden çalışır. Tek seferlik dinamik tahsisler yalnızca setup/init aşamasında (örn. Subscribe(), Register() ya da Lock() bir iç koleksiyonun kapasitesini büyütürken) ve teardown sırasında (örn. ServiceProvider.DisposeAll() temizlik için bir snapshot alırken) yapılır. Bunlar sınırlı sayıdadır, hot path dışında olur ve game loop bir kez ısındıktan sonra bir daha tekrar etmez.
  • Exceptional path'ler. Hata/exception yolları allocation yapar — fırlatılan exception'ların içindeki dinamik string'ler ya da eksik bir servis lookup'ında veya patlayan bir handler'da tetiklenen Debug.LogError çağrıları gibi. Bu beklenen ve bilinçli bir davranıştır: zaten bir şeyler ters gittiğinde çalışır ve steady-state game loop'una asla dokunmaz.
  • Ön-boyutlandırılmış compaction cache'leri. Reentrant unsubscribe sırasında (bir abonenin dispatch'in ortasında kendini kaldırması), bus etkilenen handler listelerini özel bir iç koleksiyonda (_pendingSweep) takip eder; böylece en dıştaki dispatch geri sarıldıktan sonra onları compact edebilir. Bunu hot path'ten uzak tutmak için bu sweep cache'i agresif şekilde ön-boyutlandırılmıştır; dispatch sırasında kapasitesini dinamik olarak resize etmez — reentrant-unsubscribe yolu bile allocation-free kalır.

Kurulum

Unity Package Manager üzerinden bir Git URL'i ile ekleyin:

https://github.com/acharad/corekit.git

Veya Packages/manifest.json içinde bir sürüm sabitleyin:

{
  "dependencies": {
    "com.acharad.corekit": "https://github.com/acharad/corekit.git#1.0.0"
  }
}

Bileşenlerin İncelenmesi

Events — generic kısıtlamalarla yapısal pub/sub

EventBus, değer tipli (struct) olayları gönderir. Genel API where T : struct, IEvent ile kısıtlanmıştır ve baştan sona generic'tir; bu nedenle veri (payload) hiçbir zaman object'e genişletilmez ve dolayısıyla kutulama (boxing) yaşanmaz. Fire<T>(in T), olayı salt-okunur referansla geçirerek büyük struct'larda bile savunma amaçlı kopyalamayı önler. İşleyiciler (handler) tip güvenli Action<T> delegeleri olarak saklanır; çağrı, reflection olmadan tek bir cast'tir. Gönderim FIFO ve yeniden girişe (reentrancy) güvenlidir: gönderim sırasında bir abone kaldırıldığında ilgili slot işaretlenir (tombstone) ve liste en dıştaki gönderim tamamlandıktan sonra sıkıştırılır; böylece veriyolu her gönderimde bir kopya (snapshot) tahsis etmez. Tasarımı gereği tek iş parçacıklıdır ve Unity ana iş parçacığını (main thread) hedefler.

Locking — referans sayımlı durum kapısı

ServiceLocker tek bir soruyu yanıtlar: bu sistem şu an çalışmalı mı? Farklı sebep (reason) metinlerine göre referans sayımlıdır ve herhangi bir sebep aktifken kilitli kalır. Lock("cutscene") ve Lock("loading") birbirinden bağımsız iki tutuş başlatır; locker, ikisi de bırakılana kadar IsLocked döndürür. Bu, iki ilgisiz sistemin aynı anda duraklatmaya çalıştığı ve birinin diğerini erkenden yeniden etkinleştirdiği yaygın hatayı önler. IsLocked tek bir sayım kontrolüdür.

Services — etiket tabanlı servis bulucu

ServiceProvider, tür ve isteğe bağlı bir etiket (tag) ile anahtarlanan statik bir kayıttır; böylece aynı arayüz birden çok farklı uygulamaya çözümlenebilir (AudioService ile "music" etiketli AudioService). Anahtar, elle yazılmış eşitliğe sahip bir readonly struct'tır; bu nedenle çözümleme, bellek tahsisi yapmayan O(1) sözlük (dictionary) aramasıdır. Kayıtlı her şey IService'i uygular (bu da IDisposable'ı genişletir); böylece DisposeAll(), sahne veya oturum sonunda tüm grafiği tek tip biçimde sonlandırabilir.

Ticking — birleşik yürütme döngüsü

TickService, birbirinden bağımsız N adet MonoBehaviour.Update çağrısının yerine, tek bir yerden sürdüğünüz tek bir yönetilen döngü koyar. Servisler bir kez kaydolur ve ikili arama (binary search) ile ekleme yapılarak TickPriority'ye göre sıralı tutulur; böylece yürütme sırası açık ve kareler arasında deterministiktir. Döngü listeyi indeksle gezer (enumerator tahsisi yok) ve ServiceLocker'ı devrede olan servisleri atlar; bu yüzden bir sistemi duraklatmak O(1) bir kontrole mal olur ve çöp üretmez. Sonuç: onlarca yerine tek bir yerel↔yönetilen (native↔managed) sınır geçişi ve sizin kontrol ettiğiniz bir sıra.

Hızlı Başlangıç

Modüller bir arada çalışacak şekilde tasarlanmıştır. HealthService locator üzerinden resolve edilebilir (Services) ve event'lere tepki verir (Events); asıl tick eden kısım ise ayrı bir RegenService'tir (Ticking) ve cutscene'lerde askıya alınır (Locking). Event'i ayrı bir DamageSource fire eder — bootstrap'ın işi sistemleri birbirine bağlamaktır, hasar vermek değil.

using UnityEngine;
using CoreKit.Core.Events;
using CoreKit.Core.Services;

// Değer tipli event: boxing veya heap allocation olmadan fire edilir.
public readonly struct PlayerDamaged : IEvent
{
    public readonly int Amount;
    public PlayerDamaged(int amount) => Amount = amount;
}

// Events + Services: event'lere tepki verir ve locator üzerinden resolve edilebilir.
public interface IHealthService : IService
{
    int Health { get; }
    void Heal(int amount);
}

public sealed class HealthService : IHealthService
{
    private readonly EventBus _bus;
    private int _health = 100;

    public int Health => _health;

    public HealthService(EventBus bus) => _bus = bus;

    public void Init()
    {
        ServiceProvider.Register<IHealthService>(this); // locator'a register ol
        _bus.Subscribe<PlayerDamaged>(OnDamaged);       // event'e subscribe ol
    }

    public void Heal(int amount) => _health = Mathf.Min(100, _health + amount);

    private void OnDamaged(PlayerDamaged e)
    {
        _health -= e.Amount;
        Debug.Log($"{e.Amount} hasar alindi -> {_health} HP");
    }

    public void Dispose() => _bus.Unsubscribe<PlayerDamaged>(OnDamaged);
}

Asıl tick eden kısım RegenService — health'i her kare yeniler ve Locker'ı devredeyken döngü tarafından otomatik atlanır:

using CoreKit.Core.Services;
using CoreKit.Core.Locking;
using CoreKit.Core.Ticking;

// Ticking + Locking: health'i her kare yeniler, locked iken atlanır (örn. cutscene).
public sealed class RegenService : ITickableService
{
    public int TickPriority => 0;                 // artan sıra; düşük değer daha erken işlenir
    public ServiceLocker Locker { get; } = new(); // devredeyken döngü tarafından atlanır

    private IHealthService _health;

    public void Init(TickService ticker)
    {
        ServiceProvider.Register(this); // locator'a register ol
        ticker.Register(this);          // update döngüsüne katıl
    }

    public void Tick(float deltaTime)
    {
        _health ??= ServiceProvider.Get<IHealthService>();
        _health.Heal(1); // Locker.IsLocked iken çağrılmaz
    }

    public void Dispose() { }
}

Hasar, onu asıl veren şeyden gelir — bir tuzak, bir düşman, bir yetenek — asla bootstrap'tan değil:

using CoreKit.Core.Events;

// Event'i fire eder. Hasarı veren sistemle birlikte yaşar, composition root'ta değil.
public sealed class DamageSource
{
    private readonly EventBus _bus;
    public DamageSource(EventBus bus) => _bus = bus;

    public void Hit(int amount) => _bus.Fire(new PlayerDamaged(amount));
}

Hepsini tek bir composition root'tan bağla:

using UnityEngine;
using CoreKit.Core.Events;
using CoreKit.Core.Services;
using CoreKit.Core.Ticking;

public sealed class GameBootstrap : MonoBehaviour
{
    private readonly EventBus _bus = new();
    private readonly TickService _ticker = new();

    private void Awake()
    {
        new HealthService(_bus).Init();   // Events + Services
        new RegenService().Init(_ticker); // Ticking + Locking
    }

    private void Update() => _ticker.Tick(Time.deltaTime); // tek döngü tüm servisleri sürer

    private void OnDestroy()
    {
        ServiceProvider.DisposeAll(); // kayıtlı her IService'i dispose et
        _bus.Dispose();
    }
}

Lisans

MIT © Ahmet İmran Kavraş

About

A core utility framework for Unity: an event bus, a service locator, a unified tick loop, and a state-locking primitive. Allocation-conscious, dependency-free, and designed for production game code.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages