DEV Community

Cover image for How I made item duplication impossible to represent in a game economy, with Aurora DSQL
Kaviya Kumar
Kaviya Kumar

Posted on

How I made item duplication impossible to represent in a game economy, with Aurora DSQL

I pointed ten thousand concurrent "trade" requests at a single legendary sword and tried every classic dupe trick at once: trade-window races, drop-and-relog, and the same item grabbed from two AWS regions at the same instant. After the dust settled I ran one SQL query:

SELECT count(*) FROM item_instances WHERE template_id = :legendary;  -- 1
Enter fullscreen mode Exit fullscreen mode

The answer was 1. Of those ten thousand attempts, 9,992 were rejected outright and the count never moved. It was always going to be 1, because of how the data is shaped. That is the whole idea, and this post is how it works.

The bug I was actually fighting

A dupe bug is when a player ends up with two of something that should exist once. A legendary item, a stack of gold. It has happened in New World, Diablo II, RuneScape, and plenty of others. When items carry real value, a dupe is counterfeiting, and it deflates the whole economy.

Every version of the bug is the same underlying problem wearing a costume:

  • Trade-window race: two trades move one item at the same time.
  • Drop-and-relog: the item is in the world and in the inventory at once.
  • Disconnect mid-trade: one side is credited, the other is not.
  • Cross-region: the item appears to exist in two regions and both trade it.
  • Gold double-spend: two debits read the same balance.

These are concurrency and consistency failures. So I stopped thinking about it as a game bug and started treating it as a database correctness problem.

The fix: make "owned twice" unrepresentable

I model a unique item as exactly one row, with one owner and a version number:

item_instances(instance_id PK, template_id, owner_type, owner_id, region, version, ...)
Enter fullscreen mode Exit fullscreen mode

Every transfer (trade, drop, pickup, mail) is the same conditional update:

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 it is 0, someone already moved it: abort with ITEM_MOVED.
Enter fullscreen mode Exit fullscreen mode

Two transfers that both think the item is at version 5, owned by A, cannot both succeed. The first to commit moves it to version 6. The second one either reads the new version and matches zero rows, or it conflicts at commit time (Aurora DSQL surfaces this as SQLSTATE 40001), retries, re-reads, and then matches zero rows. Exactly one wins. There is no row that says an item has two owners, so the demo can't produce one.

I run this on Aurora DSQL. It uses optimistic concurrency and reports write conflicts at commit, which is exactly the model this guard wants. I don't hide the 40001 retries. I count and display them, because they are the system doing its job under contention.

Gold is not a sword

A sword is unique. Gold is fungible. Forcing both into the same model would be wrong, so gold gets a different protection: balances that are sharded across rows (so a whale or a treasury is never a single hot row), a CHECK (balance_minor >= 0) that makes overspend structurally impossible, and a balanced double-entry ledger written in the same transaction. Every move nets to zero, so the total supply is a one-line invariant.

I fired 1,000 concurrent transfers out of one whale wallet. The supply before and after was identical: 600,000 minor units in, 600,000 out, with 598 OCC retries observed and zero errors. Nothing was created, nothing vanished.

The hardest case: two regions, one item

The cross-region dupe is the one that scares people, because a single-region database can't help you and locks don't span regions. Aurora DSQL is active-active across regions over one logical database, so I could test the real thing on a peered cluster: Tokyo (ap-northeast-1) and Seoul (ap-northeast-2).

I sent trades for the same legendary to both endpoints at the same time. Out of 482 simultaneous cross-region grabs, 482 were blocked. The count stayed 1. There is no replication-lag window to exploit, because both endpoints serialize against one source of truth.

Seeing the bug, then seeing it gone

The part that made the idea click for people was a side-by-side. I built a second, deliberately broken model: a plain inventory table with no version guard, the way a naive service might track ownership. Under a trade race, two concurrent transfers both read "A owns it" and both write a new owner, so the one legendary ends up in roughly 20 inventories. Real rows, countable with SQL.

Then I ran the same race through the kernel. The authoritative count stayed 1.

The proof, and the honest part

The whole project comes down to a reconcile step that runs the invariants as live SQL: legendary count is 1, no instance is owned twice, every item has one owner, gold supply equals what was minted, ledger drift is 0, every transaction balances, no balance is negative. All of them hold. I also wrote 12 unit tests for the pure logic (conservation, the idempotency hash, the sharding math, the backoff bounds) and they run green in CI.

The honest caveat: these guarantees hold as long as every economic action goes through the kernel. If a game has an admin minting tool or a code path that writes ownership directly, that path is outside the guarantee. Duped is the economy and trade settlement layer (trades, drops, mail, auction house, cross-region transfers), not the real-time combat loop. I would rather state that plainly than imply it covers everything.

What I learned

The thing I keep coming back to: I spent less effort "preventing" dupes and more effort making them impossible to write down. Correctness that lives in the schema doesn't depend on remembering to check. The 40001 retries felt scary at first, then became the most reassuring signal in the system, because they are proof that two writers tried to touch one row and only one was allowed through.

I also learned to show the bug before showing the fix. The version guard is a few lines of SQL. It only lands emotionally once you have watched the same attack duplicate an item in the table next to it.

The stack: Aurora DSQL as the truth core, DynamoDB as the live read model fed by a transactional outbox, Next.js on Vercel for the world. No foreign keys (DSQL doesn't have them), async indexes, money as BIGINT minor units, IAM/OIDC auth so there are no passwords in code.

Top comments (0)