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 likedaily:${$sender.account}fn— Function to execute while holding the lock. Can be sync or asyncoptions— Optional configuration:ttlSeconds(default: 10) — 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
Safe Currency Transfer
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
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
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
- Acquire:
$lockattempts to acquire a distributed lock using RedisSET NX EX(atomic set-if-not-exists with TTL) - Wait: If the lock is held by another request, it retries every
retryIntervalMsuntilmaxWaitMsis reached - Execute: Once acquired, your function runs with exclusive access to the locked resource
- Release: The lock is automatically released in a
finallyblock, even iffnthrows
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 — Acquiring a lock inside another lock with the same key will deadlock. Different keys are fine but should be avoided.
- 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.
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.