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
$assetplus a write to$globalin 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 likedaily:${$sender.account}fn— Function to execute while holding the lock. Can be sync or asyncoptions— 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 acquisitionretryIntervalMs(default: 50) — How often to retry acquiring the lock
Returns: The return value of fn
Throws:
$lock timeouterror if the lock cannot be acquired withinmaxWaitMs- Any error thrown by
fn(lock is automatically released)
Basic Example
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.
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
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
- Keep locked sections short — Only lock the minimum code that needs atomicity. Do validation outside the lock when possible.
- Use specific lock keys — Include user account, resource ID, or operation type in the key to minimize contention.
- Don’t nest locks — Re-acquiring the same key inside a lock you already hold cannot succeed: the inner call waits out
maxWaitMsand 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. - Handle timeout errors — If
$lockthrows a timeout error, the operation was not performed. Let the client retry. - Trust the auto-release — Never try to manually release the lock. The
try/finallypattern handles it.