The economy kernel,
documented for builders.
Duped is a globally consistent economy kernel for online games. It makes item and gold duplication unrepresentable in the authoritative state — as long as every economic action goes through the kernel. These docs cover the model, the public API, and how a studio adopts it.
Overview
A dupe bug lets a player end up with two of something that should exist once — a legendary blade, a stack of gold. It has wrecked game economies for 25 years (New World, Diablo II, RuneScape, EVE, WoW) because the root cause is distributed-systems correctness, not bad luck. Duped removes the root cause: every economic action is one atomic, idempotent transaction through a kernel, and uniqueness becomes a property the data model cannot violate.
Duped makes item and gold duplication unrepresentable in the authoritative state — as long as every economic action goes through the kernel. Uniqueness isn't a check you run; it's a property the data model can't express.
Each classic exploit is a concurrency failure with a precise correctness property behind it:
| Classic exploit | What goes wrong | Property enforced |
|---|---|---|
| Trade-window race | two trades move one item | version-guarded transfer |
| Drop-and-relog | item in world and inventory | atomic all-or-nothing |
| Disconnect mid-trade | one side credited, other not | atomic rollback |
| Cross-region dupe | item exists in two regions | active-active consistency |
| Gold double-spend | same coins spent twice | conditional atomic debit |
Scope — what Duped is, and isn't
Duped is the economy and trade settlement layer: trades, drops, pickups, mail, auction-house fills, gold transfers, and cross-region item movement. It is not the real-time combat loop, movement, accounts, matchmaking, or art. It owns the authoritative answer to “who owns what, and how much gold exists” — and nothing more. The guarantees hold for exactly the actions that pass through it.
Concepts
Duped models two kinds of economic object and protects each with the mechanism that actually fits it. A sword is not money — so they are never forced into the same structure.
One row per item in item_instances, with exactly one owner_id and a version. Every move — trade, drop, pickup, mail — is the same version-guarded conditional UPDATE. “Owned twice” has no representation.
Sharded balances in currency_shards with a CHECK balance_minor >= 0 that makes overspend structurally impossible, plus a balanced double-entry ledger written in the same transaction. SUM = 0 proves no inflation.
The version-guarded transfer
The entire anti-dupe guarantee for unique items is this one conditional update. The row only moves if its current (owner, version) still matches what the caller read:
UPDATE item_instances
SET owner_type = :to_type,
owner_id = :to_id,
region = :region,
version = version + 1,
updated_at = now()
WHERE instance_id = :id
AND owner_type = :from_type
AND owner_id = :from_id
AND version = :expected;
-- rowCount MUST be 1. If 0 -> someone already moved it -> abort -> ITEM_MOVEDTwo concurrent transfers cannot both match owner_id = :from AND version = :expected. The first to commit bumps the version; the second matches zero rows — or conflicts at COMMIT (DSQL surfaces OCC write conflicts as SQLSTATE 40001), retries with the same key, re-reads, and still finds zero rows. Exactly one wins, globally.
The trade kernel
executeTrade(req) is the only way the authoritative economy changes. One attempt is one DSQL transaction:
- Idempotency check — a registry hit with a matching request hash replays the stored snapshot; same key + different payload is a
409. - Item legs — each is the version-guarded update above; any miss rolls the whole trade back.
- Gold legs — a sharded conditional debit of the payer and credit of the payee; no shard covers it ⇒
INSUFFICIENT_FUNDS. - Record — trade header, provenance, the balanced ledger (when gold moved), the idempotency registry, and the event outbox, all in the same transaction.
- Commit — on
40001, roll back, jittered backoff, retry with the same key. Retries are surfaced onsnapshot.attempts, never hidden.
Invariants — the properties that always hold
Correctness is checkable as plain SQL against the truth core. The same queries power pnpm reconcile, the /api/world/proof route, and the live invariant board — so the number on the dashboard and the number in the proof are the same number.
Quickstart
Duped runs on Aurora DSQL (IAM / Vercel OIDC auth — no passwords) as the truth core and DynamoDB as the live read model. Money is always BIGINT minor units (1 gold = 100 minor); there are no foreign keys, and indexes are created ASYNC.
Bring up the world
pnpm install
pnpm db:check # SELECT NOW() — confirm DSQL connectivity
pnpm db:migrate # apply schema (one DDL per txn; indexes ASYNC)
pnpm db:index-status # wait for indexes ACTIVE before traffic
pnpm db:setup-ddb # create the DynamoDB world read-model table
pnpm db:seed # Aetheria: ONE legendary, whale gold, players
pnpm dev # the live world at http://localhost:3000Run the attacks and prove it
pnpm storm # the dupe storm — thousands of bots vs ONE legendary
pnpm storm --gold # the gold double-spend storm
pnpm storm --market --sweep # independent-trade throughput sweep (scale)
pnpm reconcile # the SQL proof: legendary=1, gold conserved, drift=0
pnpm projector # drain the outbox -> DynamoDB (run during/after a storm)Local dev uses .env.local (a single-region cluster). For the real Tokyo⇄Seoul cross-region demo, use .env.mr (a peered cluster), e.g. pnpm exec tsx --env-file=.env.mr scripts/<script>.ts.
API Reference
TradeRequest, returns a TradeSnapshot.A single call settles a two-sided atomic exchange. A DROP, PICKUP, or MAIL is the same machinery with WORLD / MAIL as one side. Always send a stable idempotencyKey — the same key with the same payload replays the stored snapshot exactly.
Request — TradeRequest
409.GOLD).ITEM_MOVED.PLAYER | WORLD | ESCROW | MAIL.Response — TradeSnapshot
DECLINED is a deterministic business outcome, not an error.instanceId, fromOwnerId, toOwnerId, versionAfter. Present on commit.Outcomes & failure codes
HTTP status codes
Example — settle a trade
curl -X POST https://your-app.vercel.app/api/v1/trades \
-H "Content-Type: application/json" \
-d '{
"realmId": "aetheria",
"idempotencyKey": "trade-7f3c1a-001",
"kind": "TRADE",
"playerA": "player-aria",
"playerB": "player-kade",
"itemLegs": [
{
"instanceId": "blade-of-dawn-0001",
"expectedVersion": 4,
"fromOwnerType": "PLAYER", "fromOwnerId": "player-aria",
"toOwnerType": "PLAYER", "toOwnerId": "player-kade"
}
],
"goldLegs": [
{ "fromPlayerId": "player-kade", "toPlayerId": "player-aria", "amountMinor": 250000 }
],
"currency": "GOLD",
"region": "TOKYO"
}'// 201 Created
{
"outcome": "COMMITTED",
"tradeId": "trade-2b9e...",
"kind": "TRADE",
"movedItems": [
{ "instanceId": "blade-of-dawn-0001",
"fromOwnerId": "player-aria", "toOwnerId": "player-kade",
"versionAfter": 5 }
],
"goldMovedMinor": 250000,
"ledgerTxnId": "ldg-04c7...",
"attempts": 2,
"replayed": false,
"committedAt": "2026-06-27T10:14:02.118Z"
}World read endpoints
These serve the DynamoDB read model and the demo levers (read-only telemetry plus the storm controls):
Architecture
One truth core, one read plane, one deploy target. The write path is a single atomic transaction; the read plane is fed asynchronously and only ever projected, never written inside a trade.
game clients / dupe-attack bots
│ POST /api/v1/trades (TradeRequest)
▼
┌──────────────────────────┐ executeTrade() — one idempotent,
│ THE TRADE KERNEL │ atomic, OCC-retrying transaction
└────────────┬─────────────┘
│ same txn: item_instances · currency_shards · trades
│ item_moves · economy_ledger_* · idempotency_registry · event_outbox
▼
╔════════════════════════╗ outbox ╔═════════════════════════╗
║ AURORA DSQL ║──projector─▶ ║ DynamoDB ║
║ truth core ║ (async) ║ live world read model ║
║ Tokyo ⇄ Seoul ║ ╚═════════════════════════╝
║ active-active ║ │
╚════════════════════════╝ ▼
▲ world arena + console (Vercel)
invariant board (live SQL)The single source of truth. A multi-region peered cluster is one logical database over two strongly-consistent endpoints (Tokyo primary ap-northeast-1, Seoul secondary ap-northeast-2). Optimistic concurrency; no foreign keys; CREATE INDEX ASYNC; money as BIGINT minor units.
The read plane only. Written exclusively by the transactional-outbox projector via idempotent PutItem — never inside a trade. CQRS: the write model stays minimal and serializable, reads scale out.
Next.js 15 (App Router). Auth is IAM-token based — Vercel OIDC at runtime, the AWS credential chain locally. No passwords or secrets in code.
Exactly-once, end to end
Two pieces make the write path safe to retry. The idempotency registry (keyed realm_id, idempotency_key) stores the canonical snapshot, so a retried or duplicated request replays byte-identically instead of double-applying. The event outbox is written inside the same transaction as the economic change, then drained to DynamoDB by the projector — so the read model can never describe a state the truth core didn't commit.
Integration
Adoption is a single discipline: route every economic action through the kernel. A trade, a drop, a pickup, mail, an auction fill, a gold transfer — all of them become a TradeRequest to executeTrade / POST /api/v1/trades, each carrying a stable idempotency key.
- Model your objects in two buckets. Unique items become
item_instancesrows; fungible currencies become shardedcurrency_shards+ ledger. - Express each action as legs. Item legs carry the
expectedVersionyou read; gold legs carry minor units. A drop is an item leg toWORLD; a sale is item legs one way and gold legs the other. - Derive a deterministic idempotency key per logical action so client retries, disconnects, and replays settle exactly once.
- Read from the projection, not the truth core. Inventories, feeds, and dashboards read DynamoDB; the kernel keeps the write path lean.
- Surface retries, don't hide them. Trust
snapshot.attemptsand the failure codes as first-class outcomes in your client.
The guarantees hold for exactly the actions that pass through the kernel. If a path bypasses executeTrade and mutates ownership or balances directly, it bypasses the proof too. Duped makes duplication unrepresentable in the authoritative state — so make the kernel the only door into that state.