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
  • Currency transfers: Ensure balance checks and deductions are atomic
  • 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) — 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

Safe Currency Transfer

server/src/server.ts
export class Server { async transferGold(toAccount: string, amount: number): Promise<{ success: boolean }> { if (amount <= 0) throw new Error('Invalid amount'); if (toAccount === $sender.account) throw new Error('Cannot transfer to yourself'); return $lock(`transfer:${$sender.account}`, async () => { const balance = await $asset.get('gold'); if (balance < amount) { throw new Error('Insufficient gold'); } await $asset.burn('gold', amount); // Note: minting to another account should also be protected return { success: true }; }); } }

Atomic Shared Counter

server/src/server.ts
export class Server { async incrementGlobalCounter(): Promise<{ count: number }> { return $lock('global-counter', async () => { const state = await $global.getGlobalState(); const count = (state?.counter ?? 0) + 1; await $global.updateGlobalState({ counter: count }); return { count }; }); } }

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

  1. Acquire: $lock attempts to acquire a distributed lock using Redis SET NX EX (atomic set-if-not-exists with TTL)
  2. Wait: If the lock is held by another request, it retries every retryIntervalMs until maxWaitMs is reached
  3. Execute: Once acquired, your function runs with exclusive access to the locked resource
  4. Release: The lock is automatically released in a finally block, 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 — Acquiring a lock inside another lock with the same key will deadlock. Different keys are fine but should be avoided.
  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.

Conclusion

The $lock primitive enables safe concurrent operations on the Agent8 GameServer. Use it whenever you need to read state, check a condition, and write state as an atomic operation. For simple reads or independent writes, locks are unnecessary overhead.

Last updated on