Skip to main content
Data layer · ConjureDBshipped

The hidden data layer in your client, compiled.

Every game client already contains a data layer, written by hand: dictionaries as indexes, nested lookups as joins, OnChanged subscriptions as cache invalidation. ConjureDB is that layer declared once and compiled to plain C# — the part of Conjure you can use today.

0 Bper frame on the query hot path
O(1)indexed lookups
519×vs hand-written LINQ over lists, 57-query workload
AOTIL2CPP-safe, no reflection

57-query game workload, 50 000 players; full methodology below.

Why the usual fixes don't hold

Every familiar tool trades clean code for runtime cost

Each option is good at something — and each breaks on either maintainability or the frame budget. That dilemma is the real problem.

ScriptableObjects & manager singletons

Fine for config and small state. As soon as data must be queried, joined and kept consistent, they become a database with no schema, no indexes and no query planner — maintained entirely in your head.

Manual dictionaries + hand-rolled indexes

They work until a second index has to stay in sync with the first. Then every write path must remember to update every derived structure, and the ones it forgets become your bugs.

An event bus for "refresh on change"

It starts clean and becomes its own spaghetti: refresh order is implicit, over-refreshing burns the frame, under-refreshing ships stale UI, and nobody can safely add a dependency.

Just embed an SQL engine or an ORM

Now you pay it all at runtime: query parsing, planning, reflection, allocations and millisecond latency — none of which fit IL2CPP or a per-frame budget measured in microseconds.

The reframe

You already know every query before you ship.

Your game ships with a fixed set of screens and questions. The shop runs the same offer query it ran yesterday; the inventory the same lookup. You know the whole catalogue of questions your game will ever ask before the build leaves your machine — so there's no reason to work them out again on the player's device, every frame.

ConjureDB keeps the one good idea — describe data declaratively, in one place — and moves the entire cost from runtime to build time. The compiler does once, correctly, what you'd otherwise do by hand, every frame, slightly wrong.

What it buys you

Four wins from one decision

Features that travel between games

Behind a declarative schema, a feature owns its data on its own terms — not welded into one game's managers and presenters. Build a shop or inventory once and carry it into the next project.

Fast without hand-optimizing

A cost-based optimizer turns the lists you'd loop over into index lookups, and picks the plan for you. You stop hand-tuning data code and the frame budget stops being the thing you fight.

UI views that refresh themselves

Incremental view maintenance keeps a view model current by applying only what changed, so the refresh code you would write by hand never exists. Shapes it can't maintain are rejected at compile time, not silently degraded.

It stays manageable as it grows

One home for data logic instead of a tangle across scripts. The coupling that makes a growing game unmaintainable goes away — so the project stays workable from 3 systems to 30.

Where the speed comes from

Four structural reasons

Nothing is interpreted

Queries are compiled to C# at build time. There is no parser or planner on the hot path at runtime.

Indexes you declare

A matching index turns a full scan into a direct lookup or a bounded range walk — chosen by the optimizer.

Allocation-lean paths

Struct storage, ref returns and pooled buffers keep query evaluation off the heap; only the result collection is materialized, so per-query GC stays low.

Deltas, not recomputes

Incremental view maintenance updates reactive results by the size of the change, not the size of the table.

How IVM works →

Against what you write today

vs hand-written C#

The honest baseline is not a database. It is the LINQ over lists and the Dictionary you would otherwise write.

Typical query time · geomean of 35 real-work cases
Hand-written LINQ
37.3 ms
ConjureDB
71.8 µs
Speedup
Hand-written LINQ
—
ConjureDB
~519× faster
Allocated · all 57 cases
Hand-written LINQ
1007.4 MB total
ConjureDB
0 B on 26 of 57

vs a plain Dictionary — raw reads

50,000 items, fair fight: the Dictionary gets preset capacity and does none of the work — no transactions, no indexes, no change tracking, no replay-safe commands. ConjureDB still wins primary-key lookups by using a dense-id index and runs level on a contiguous scan.

OperationWhat it doesDictionaryConjureDBResult
SearchLook up 50k items by id (dense-id index = a direct array hit)753 µs56.0 µs13× faster
IterateScan all 50k — a flat contiguous span87.1 µs88.4 µs≈ parity

vs a plain Dictionary — CRUD writes

5,000,000 operations, median shown. The known-size Dictionary column uses preset capacity; the default-capacity column shows the common missed-capacity case for insert-like operations where resize/rehash happens inside the measured work.

OperationWhat it doesDictionary known sizeDictionary defaultConjureDBResult vs known size
Add — batchedInsert 5M with bulk Add(T[]) in one transaction22.5 ms33.5 ms28.5 ms1.3× slower
Add — explicit tx / 10 callsInsert 5M via BeginTransaction + 10 Add calls + Commit22.5 ms33.5 ms122 ms5.4× slower
Add — bulk API / 10 rowsInsert 5M via BeginTransaction + Add(T[10]) + Commit22.5 ms33.5 ms60.3 ms2.7× slower
Add — direct 1 / commitInsert 5M via the direct single-row commit API22.5 ms33.5 ms23.8 ms≈ parity
Add — generated mutationInsert 5M through the generated command/mutation API22.5 ms33.5 ms27.2 ms1.2× slower
Update — direct 1 / commitUpdate 5M existing rows through the direct single-row commit API15.0 ms—15.8 ms≈ parity
Update — generated mutationUpdate 5M existing rows through the generated command/mutation API15.0 ms—21.8 ms1.5× slower
Upsert — update existingUpsert 5M existing rows through the generated command/mutation API14.9 ms—22.8 ms1.5× slower
Upsert — insert missUpsert 5M missing rows through the generated command/mutation API22.3 ms34.0 ms38.2 ms1.7× slower
Remove — batchedDelete 5M with bulk Remove(int[]) in one transaction16.0 ms—18.0 ms1.1× slower
Remove — explicit tx / 10 callsDelete 5M via BeginTransaction + 10 Remove calls + Commit16.0 ms—97.8 ms6.1× slower
Remove — bulk API / 10 rowsDelete 5M via BeginTransaction + Remove(int[10]) + Commit16.0 ms—47.0 ms2.9× slower
Remove — direct 1 / commitDelete 5M via the direct single-row commit API16.0 ms—19.8 ms1.2× slower
Remove — generated mutationDelete 5M through the generated command/mutation API16.0 ms—19.7 ms1.2× slower
Frame state

vs a hand-written ECS and Unity Entities

Per-frame state measured on the Arena sample: 100 000 units, projectiles, hits, respawns, same RNG draw order and identical digests.

UnitsEngineFrame p50
100 kConjureDB, job graphs + Burst collision171 µs
100 kDOTS baseline190 µs
1 MConjureDB, job graphs + Burst collision453 µs
1 MDOTS baseline491–513 µs

0 B/frame in every system. Full numbers and what they taught us →

Against an embedded SQL engine

Kept for completeness: an in-memory, compiled, closed-world engine should beat a general-purpose SQL engine on a known workload. The interesting comparisons are the two above.

LINQ vs SQLite vs ConjureDB

The same queries, the same 50,000-row data, three ways to run them: the hand-written LINQ a developer reaches for first, embedded SQLite, and ConjureDB. Here is how they stack up.

Typical query time — geometric mean across 35 real-work cases · bar length ∝ time (lower is better)
Hand-written LINQ
37.3 ms519× slower
SQLite
17.2 ms239× slower
ConjureDB
71.8 µsbaseline
~519×faster than hand-written LINQ (geomean)
~239×faster than SQLite (geomean)
0 BConjureDB allocations on 26 of 57 cases
31/57cases where naive LINQ is slower than SQLite

Fair by construction. Those geomeans deliberately set aside 22 cases that resolve to a sub-microsecond index probe (primary- and foreign-key lookups, top-N off a sorted index) — there ConjureDB does almost no work and the ratio is effectively unbounded, which would flatter the average. Counting all 57 cases it is ~497× vs SQLite and ~3,400× vs LINQ; the numbers above are the conservative slice that still does real scanning, joining and aggregation.

It’s index hits, not magic. The advantage is a declared index turning a full O(n) — or O(n·m) — scan into an O(1)/O(log n) probe. Hand-written LINQ over a plain List<T> has no index, so it walks everything; on correlated queries that is quadratic, which is why naive LINQ lands slower than SQLite in 31 of 57 cases — SQLite at least has a planner and indexes.

Allocations tell the same story. ConjureDB’s best variant allocates 0 B on 26 of 57 cases — it streams from indexes into caller-owned buffers. The naive LINQ churns a geomean of 2.3 MB per query (1007.4 MB across the suite), all of it pressure on the GC — which on a frame budget is its own tax.

And this is the floor, not the ceiling. These are one-shot evaluations — ConjureDB runs the query against its indexes on every call, same as SQLite and the LINQ, and with no incremental view maintenance. For hot, repeatedly-read queries it can go further with IVM: instead of re-evaluating, it applies O(changes) deltas as data is written and reads just return the maintained result. That moves cost to write time — proportional to what changed, not the dataset, and off the synchronous read path — a deliberate read/write trade these benchmarks don’t use.

The coverage

Every query shape, measured

The verdict above isn't one cherry-picked query. It holds across a governed taxonomy of 12 query families — 57 BenchmarkDotNet cases vs SQLite at 50,000 players, spanning filters, lookups, joins, aggregation, windows, semi/anti/exists, set operations, outer joins, distinct and sort/pagination. All 56 production-tier cases run ≥50×, none slower; the weakest is 54.81×. Every row carries all three baselines side by side — open one for the ConjureDB, LINQ and SQL form and the per-engine timings. Browse by family:

Filter scan and streaming projection

2 cases · 120×–8,807× vs SQLite

Three baselines, same 50,000-row dataset. Select a query for its ConjureDB, LINQ and SQL form.

QueryLINQSQLiteConjureDBvs SQLiteAlloc

Methodology: ConjureDB and SQLite figures are from the project's authoritative all-case BenchmarkDotNet proof (57 cases) vs SQLite, Release, .NET 8, 50,000-row datasets; the best-measured ConjureDB variant is shown per query. The LINQ baseline is measured separately on the identical dataset and query parameters (in-process timing). These are measurements of one configuration — not a guarantee. Your game is different: schema, hardware and generated plan all matter, so benchmark your own workload. Raw benchmark data ships with the source.

The other lane

And a no-JIT lane for config-delivered queries

Everything above is the compiled lane: queries you know at build time, lowered to C# with nothing to interpret at runtime. But some queries ship later — as content or config — and on iOS and Unity/IL2CPP you can't JIT or generate code at runtime to run them. ConjureDB runs those on a portable interpreter: a pre-verified bytecode executed with no JIT, no codegen and no reflection. It's a deliberate second lane, not a fallback — and it stays fast and allocation-lean.

4.2–50×faster than SQLite across 23 relational shapes
23 / 23relational scenarios faster than SQLite
~18×indexed range top-N vs portable full scan
0 Ballocation on a keyed point lookup

No JIT, no codegen

Config-delivered queries ship as a pre-verified bytecode and run through a register-based interpreter — no runtime IL emission, no reflection — so they work on iOS and Unity/IL2CPP where a JIT cannot.

Allocation-light runtime

Pooled hashtables and arena-backed key/accumulator buffers keep allocation tiny: 0 bytes for a point lookup, and no current 23-scenario vs-SQLite corpus case above 152 bytes per query.

Index-backed access

A primary-key probe is constant-time at about 49 ns in the current vs-SQLite harness, and secondary-index plans use the same ordered accessors as the compiled path. Indexed range top-N is about 18× faster than the portable full-scan variant.

Parity, not approximation

The interpreter produces bit-identical results to the AOT C# lane — same rows, ordering and null semantics — continuously verified by differential and SQL-parity tests.

Every operator, measured against SQLite

The fair fight: SQLite is itself a bytecode interpreter, so this is interpreter vs interpreter. Each of the 23 shapes runs the same query over the same 1000/1000/500-row Players/Orders/Products corpus data, with matching indexes for each scenario and prepared statements — and the no-JIT lane wins all 23, from 4.2–50×, while keeping point lookups at 0 B and the heaviest current 23-scenario corpus case at 152 B.

Query shapeWhat it doesSQLiteConjureDBvs SQLiteAllocated
Point lookupFetch one row by primary key634 ns49 ns13× faster0 B
Indexed range top-NUse score index for range filter + top 107.59 µs433 ns18× faster40 B
Indexed stock top-NUse stock index for range filter + top 102.54 µs525 ns4.8× faster40 B
Indexed score lookupUse score index for equality lookup22.9 µs1.51 µs15× faster40 B
Sort top-NSort by price, take 1017.8 µs2.57 µs6.9× faster152 B
DistinctDistinct values of one column31.0 µs2.98 µs10× faster40 B
Multi-column distinctDistinct over two columns165 µs4.28 µs38× faster96 B
Hash aggregateCount rows grouped by a column110 µs4.28 µs26× faster96 B
Indexed scan · filter · projectUse level index, then project three columns174 µs6.26 µs28× faster40 B
Set unionUnion two result sets (dedup)126 µs6.30 µs20× faster97 B
Set intersectIntersect two result sets (dedup)128 µs6.34 µs20× faster97 B
Score lookupScan/filter rows by score equality37.8 µs6.60 µs5.7× faster40 B
Scan · filter · projectFilter a table, project three columns160 µs7.60 µs21× faster41 B
Range top-NFilter, sort by score, take 1033.4 µs7.81 µs4.3× faster152 B
Multi-key aggregateCount + sum grouped by two columns301 µs9.30 µs32× faster97 B
Aggregate · double keySum grouped by a floating-point column471 µs9.69 µs49× faster42 B
Window · row_numberrow_number() over a sorted partition490 µs11.5 µs43× faster129 B
Product stock top-NFull-scan filter, sort by stock, take 1048.3 µs11.5 µs4.2× faster152 B
Multi-aggregateCount/sum/min/max grouped by a column152 µs12.7 µs12× faster40 B
Window · running sumRunning SUM() over an ordered frame772 µs15.5 µs50× faster99 B
Aggregate · string keyCount grouped by a string column404 µs15.9 µs25× faster42 B
Hash joinInner-join two tables on a key381 µs17.8 µs21× faster3 B
Complex expressionArithmetic projection, then filter on the result242 µs25.6 µs9.4× faster147 B

Some of the win is structural: an in-process engine on your own heap skips the managed↔native marshaling SQLite pays per query — which is exactly why you'd embed one on a client. And the compiled C# lane above is still the throughput ceiling; keep your hottest, build-time-known queries there. The portable lane's job is to run the queries the compiled lane structurally can't, on platforms a JIT can't — and to still beat SQLite doing it. The v1 superinstruction pass is measured separately: fused opcode hit scenarios improve+6.5% geomean, with the rest of the corpus flat.

Methodology: Apple M4 Max · .NET 8 · single thread, BenchmarkDotNet, IterationCount=8 after 4 warmups with BenchmarkDotNet's reported outlier filtering. Both engines run the same query over the same 1000/1000/500-row Players/Orders/Products corpus data with a primary-key index plus matching secondary indexes for index-aware scenarios, prepared statements, and the full result materialized; SQLite is in-memory with fast pragmas (synchronous=OFF, temp_store=MEMORY). “vs SQLite” is SQLite's mean ÷ ConjureDB's mean. One configuration — not a guarantee. Benchmark your own workload.

Honest limits

When ConjureDB is the wrong choice

Arbitrary runtime queries

If designers must type new queries that run on player devices, you need a runtime engine. ConjureDB is closed-world: queries are known and compiled before ship. Changes after the build go through the liveops lane as compiled artifacts.

Trivially small or throwaway state

Three booleans behind an options menu, or a prototype you delete in two weeks, do not need a schema. A dictionary is the right answer.

A multi-writer server database

This is single-writer and client-owned — no MVCC, no row locks, no distributed transactions. It is a fast local layer, not a contention-solving backend.

A source of authority on its own

The client data layer is a fast local replica, not the truth. In Conjure the truth is the server side, which verifies the device by replay; that part is a validated prototype, not yet in the product.

The cost is real, too: a schema to maintain, a build step, and a new way of thinking for your team. We think it is worth it for a growing game that wants to keep both its architecture and its frame budget — but you should decide with eyes open.

Speed you can audit

Every number on this page is one configuration, measured with its method published. Your schema, hardware and plans differ — benchmark your own scenario before you decide.