Skip to Content
Game ServerDistributed Lock

Distributed Lock ($lock)

Agent8 provides a distributed lock primitive for ensuring atomic read-modify-write operations on the server. The $lock function prevents race conditions when multiple concurrent requests need to modify the same state.

Understanding the Lock System

In multiplayer games, multiple requests from the same user (e.g., double-clicking a button) or different users (e.g., competing for a shared resource) can arrive simultaneously. Without locking, a read-modify-write sequence can be interleaved, leading to double rewards, lost updates, or inconsistent state.

$lock is a server-side only function. It acquires a distributed lock, executes your function, then automatically releases the lock — even if the function throws an error.

When to Use $lock

✅ Use $lock For:

  • Daily reward claims: Prevent double-claiming by the same user
  • Multi-store updates: A balance check in $asset plus a write to $global in one step (a player-to-player transfer does not need a lock — use $asset.transfer)
  • Shared counters: Increment/decrement without lost updates
  • Inventory operations: Prevent duplicate item grants
  • Any read-modify-write pattern: Where concurrent access could cause bugs

❌ Don’t Use $lock For:

  • Simple reads: $global.getMyState() doesn’t need a lock
  • Independent writes: $asset.mint() is already atomic on its own
  • Operations without state dependency: If the write doesn’t depend on a prior read

Server API

The $lock function is available as a global in server code:

$lock<T>(lockKey: string, fn: () => T | Promise<T>, options?: LockOptions): Promise<T>

Parameters:

  • lockKey — Unique identifier for the lock. Use descriptive, scoped keys like daily:${$sender.account}
  • fn — Function to execute while holding the lock. Can be sync or async
  • options — Optional configuration:
    • ttlSeconds (default: 10, minimum 1 — smaller values are silently raised) — Lock auto-expires after this duration (safety net for crashes)
    • maxWaitMs (default: 5000) — Maximum time to wait for lock acquisition
    • retryIntervalMs (default: 50) — How often to retry acquiring the lock

Returns: The return value of fn

Throws:

  • $lock timeout error if the lock cannot be acquired within maxWaitMs
  • Any error thrown by fn (lock is automatically released)

Basic Example

server/src/server.ts
export class Server { async claimDailyReward(): Promise<{ success: boolean; gold: number }> { return $lock(`daily:${$sender.account}`, async () => { const state = await $global.getMyState(); const today = new Date().toISOString().slice(0, 10); if (state?.lastClaimDate === today) { throw new Error('Already claimed today'); } await $asset.mint('gold', 100); await $global.updateMyState({ lastClaimDate: today }); const assets = await $asset.getAll(); return { success: true, gold: assets.gold ?? 0 }; }); } }
⚠️

Lock Key Design: Always include $sender.account in lock keys for per-user operations. Using a generic key like 'claim' would serialize ALL users, destroying performance. Use specific keys like daily:${$sender.account} so different users can operate concurrently.

More Examples

Read-Modify-Write Across Two Stores

⚠️

Do not hand-roll a player-to-player transfer. $asset.transfer(toAccount, assetId, amount) already checks the balance, debits the sender and credits the recipient. A hand-written get → burn sequence is easy to get wrong in the direction that destroys currency — burning without the matching mint.

server/src/server.ts
export class Server { // Buying an item is a read-modify-write that spans TWO stores — the gold // balance in $asset and the inventory list in $global. No single call covers // both, so without a lock two concurrent buys can both pass the price check. async buyItem(itemId: string, price: number): Promise<{ inventory: string[] }> { return $lock(`buy:${$sender.account}`, async () => { const gold = await $asset.get('gold'); if (gold < price) throw new Error('Insufficient gold'); await $asset.burn('gold', price); const state = await $global.getMyState(['inventory']); const inventory = [...(state.inventory || []), itemId]; await $global.updateMyState({ inventory }); return { inventory }; }); } }

A shared counter is the same pattern — see the Global Counter.

Custom Lock Options

server/src/server.ts
export class Server { async criticalOperation(): Promise<void> { return $lock('critical-section', async () => { // Long-running operation const data = await $global.getGlobalState(); // ... complex processing ... await $global.updateGlobalState(data); }, { ttlSeconds: 30, // Allow up to 30s before auto-expiry maxWaitMs: 10000, // Wait up to 10s to acquire retryIntervalMs: 100, // Check every 100ms }); } }

How It Works

$lock sets the key only if it is not already held, with a TTL of ttlSeconds. While another request holds it, it retries every retryIntervalMs until maxWaitMs, then throws $lock timeout. Once acquired, fn runs and the lock is released in a finally — even if fn throws.

🚫

TTL Safety Net: If your server crashes while holding a lock, the TTL ensures the lock is eventually released. However, if your fn takes longer than ttlSeconds, another request may acquire the lock before yours finishes. Keep locked operations fast.

Best Practices

  1. Keep locked sections short — Only lock the minimum code that needs atomicity. Do validation outside the lock when possible.
  2. Use specific lock keys — Include user account, resource ID, or operation type in the key to minimize contention.
  3. Don’t nest locks — Re-acquiring the same key inside a lock you already hold cannot succeed: the inner call waits out maxWaitMs and then throws $lock timeout. It is a bounded failure, not a hang, but it is still always a bug. Different keys work, but nesting them invites lock-ordering deadlocks between requests — avoid it.
  4. Handle timeout errors — If $lock throws a timeout error, the operation was not performed. Let the client retry.
  5. Trust the auto-release — Never try to manually release the lock. The try/finally pattern handles it.
Last updated on