Bank System
The banking system provides persistent item storage separate from the player’s inventory. Following OSRS/RS3 design, each town has its own bank and players can organize items into tabs.
Banking code lives in packages/shared/src/systems/shared/economy/BankingSystem.ts.
Core Features
- Per-town banks - Each starter town has its own bank
- Independent storage - Banks don’t share items across towns
- 480 slot capacity - Per bank (configurable via
BANKING_CONSTANTS.MAX_BANK_SLOTS)
- Tab organization - Organize items into custom tabs (9 tabs: 1 main + 8 custom)
- Placeholders - RS3-style placeholder mode for remembered slots
- Two-phase slot updates - Prevents database constraint violations during bulk operations
- Distance check - Must be within 3 meters of bank booth
- Combat interruption - Bank automatically closes when player is attacked (OSRS-accurate)
Combat Closes Bank: Being attacked automatically closes the bank interface (OSRS-accurate). Even splash attacks (0 damage) will interrupt banking. This is server-authoritative - the server sends a bankClose packet with reason: "combat".
Bank Constants
// From BankingConstants.ts
export const BANKING_CONSTANTS = {
MAX_BANK_SLOTS: 480, // Slots per bank
INTERACTION_RANGE: 3, // Max distance to use bank (meters)
MAX_TABS: 9, // Tab count (1 main + 8 custom)
PLACEHOLDER_ITEM_ID: "placeholder", // Special item ID for placeholders
};
// Starter town banks
private readonly STARTER_TOWN_BANKS = [
{ id: "bank_town_0", name: "Central Bank", position: { x: 0, y: 0, z: 5 } },
{ id: "bank_town_1", name: "Eastern Bank", position: { x: 100, y: 0, z: 5 } },
{ id: "bank_town_2", name: "Western Bank", position: { x: -100, y: 0, z: 5 } },
{ id: "bank_town_3", name: "Northern Bank", position: { x: 0, y: 0, z: 105 } },
{ id: "bank_town_4", name: "Southern Bank", position: { x: 0, y: 0, z: -95 } },
];
Bank Data Structure
interface BankData {
items: BankItem[]; // Items stored in bank
maxSlots: number; // 480 per bank
}
interface BankItem {
id: string; // Item definition ID
name: string; // Item name
quantity: number; // Stack quantity
stackable: boolean; // Can items stack
}
// Player bank tracking
private playerBanks = new Map<PlayerID, Map<BankID, BankData>>();
private openBanks = new Map<PlayerID, BankID>(); // Currently open bank
Bank Operations
Opening a Bank
private openBank(data: { playerId: string; bankId: string; playerPosition?: Position3D }): void {
const playerId = createPlayerID(data.playerId);
const bankId = createBankID(data.bankId);
// Check if already open (prevent recursive calls)
if (this.openBanks.get(playerId) === bankId) return;
// Distance check
const bankInfo = this.STARTER_TOWN_BANKS.find(b => b.id === data.bankId);
if (bankInfo && data.playerPosition) {
const distance = calculateDistance(data.playerPosition, bankInfo.position);
if (distance > 3) {
this.emitTypedEvent(EventType.UI_MESSAGE, {
playerId: data.playerId,
message: "You need to be closer to the bank to use it.",
type: "error",
});
return;
}
}
// Track open bank
this.openBanks.set(playerId, bankId);
// Send bank data to client
this.emitTypedEvent(EventType.UI_UPDATE, {
playerId: data.playerId,
component: "bank",
data: {
bankId: data.bankId,
bankName: bankInfo?.name || "Bank",
items: bank.items,
maxSlots: bank.maxSlots,
usedSlots: bank.items.length,
isOpen: true,
},
});
}
Depositing Items
private depositItem(data: BankDepositEvent): void {
const playerId = createPlayerID(data.playerId);
const bankId = this.openBanks.get(playerId);
if (!bankId) {
console.warn("[BankingSystem] No bank open for player:", playerId);
return;
}
const bank = this.playerBanks.get(playerId)?.get(bankId);
if (!bank) return;
// Check if inventory has the item
this.emitTypedEvent(EventType.INVENTORY_CHECK, {
playerId: data.playerId,
itemId: data.itemId,
quantity: data.quantity,
callback: (hasItem, itemInfo) => {
if (!hasItem || !itemInfo) {
this.emitMessage(data.playerId, "Item not found in inventory.", "error");
return;
}
// Remove from inventory
this.emitTypedEvent(EventType.INVENTORY_ITEM_REMOVED, {
playerId: data.playerId,
itemId: data.itemId,
quantity: data.quantity,
});
// Check for existing stack
const existingItem = bank.items.find(item => item.id === data.itemId);
if (existingItem) {
existingItem.quantity += data.quantity;
} else {
// Check bank capacity
if (bank.items.length >= bank.maxSlots) {
this.emitMessage(data.playerId, "Bank is full.", "error");
// Refund item
this.emitTypedEvent(EventType.INVENTORY_ITEM_ADDED, { ... });
return;
}
bank.items.push({
id: data.itemId,
name: itemInfo.name,
quantity: data.quantity,
stackable: itemInfo.stackable,
});
}
this.emitTypedEvent(EventType.BANK_DEPOSIT_SUCCESS, { ... });
this.updateBankInterface(data.playerId, bankId);
},
});
}
Withdrawing Items
private withdrawItem(data: BankWithdrawEvent): void {
const playerId = createPlayerID(data.playerId);
const bankId = this.openBanks.get(playerId);
const bank = this.playerBanks.get(playerId)?.get(bankId);
// Find item in bank
const bankItemIndex = bank.items.findIndex(item => item.id === data.itemId);
if (bankItemIndex === -1) {
this.emitMessage(data.playerId, "Item not found in bank.", "error");
return;
}
const bankItem = bank.items[bankItemIndex];
if (bankItem.quantity < data.quantity) {
this.emitMessage(data.playerId, "Not enough of that item in bank.", "error");
return;
}
// Remove from bank
bankItem.quantity -= data.quantity;
if (bankItem.quantity <= 0) {
bank.items.splice(bankItemIndex, 1);
}
// Add to inventory
this.emitTypedEvent(EventType.INVENTORY_ITEM_ADDED, {
playerId: data.playerId,
item: {
id: generateUniqueId(),
itemId: bankItem.id,
quantity: data.quantity,
slot: -1, // Find empty slot
metadata: null,
},
});
this.updateBankInterface(data.playerId, bankId);
}
Deposit All
private depositAllItems(data: { playerId: string; bankId: string }): void {
const inventory = this.playerInventories.get(playerId);
const bank = this.playerBanks.get(playerId)?.get(bankId);
let itemsDeposited = 0;
for (const item of inventory.items) {
if (bank.items.length >= this.MAX_BANK_SLOTS) {
this.emitMessage(playerId, `Bank is full! Deposited ${itemsDeposited} items.`, "warning");
break;
}
bank.items.push({
id: item.itemId,
name: "",
quantity: item.quantity,
stackable: true,
});
this.emitTypedEvent(EventType.INVENTORY_ITEM_REMOVED, { ... });
itemsDeposited++;
}
if (itemsDeposited > 0) {
this.emitMessage(playerId, `Deposited ${itemsDeposited} items into the bank.`, "success");
}
}
Placeholder System
Banks support RS3-style placeholders that remember item positions:
Release All Placeholders
The “Release All Placeholders” operation uses a two-phase slot update to prevent unique constraint violations:
// From placeholders.ts
for (const tabIndex of affectedTabs) {
// Phase 1: Add large offset to move all slots far from target range
await tx.execute(
sql`UPDATE bank_storage
SET slot = slot + ${SLOT_OFFSET_TEMP}
WHERE "playerId" = ${ctx.playerId} AND "tabIndex" = ${tabIndex}`,
);
// Phase 2: Renumber slots sequentially using ROW_NUMBER
// Source slots are now ≥1000, target slots are 0-N, no overlap possible
await tx.execute(
sql`UPDATE bank_storage
SET slot = subq.new_slot
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY slot) - 1 as new_slot
FROM bank_storage
WHERE "playerId" = ${ctx.playerId} AND "tabIndex" = ${tabIndex}
) as subq
WHERE bank_storage.id = subq.id`,
);
}
Why Two-Phase?
PostgreSQL doesn’t guarantee UPDATE execution order. Direct renumbering could cause collisions:
- Row A updates from slot 10→6
- Row B updates from slot 6→3
- If Row A executes first, both rows temporarily occupy slot 6 → unique constraint violation
The two-phase approach eliminates this by ensuring source and target slot ranges never overlap:
- Phase 1: All slots move to 1000+ range
- Phase 2: All slots renumber to 0-N range
- No collision possible since 1000+ and 0-N don’t overlap
The same two-phase pattern is used in compactBankSlots() for consistent slot renumbering.
Bank Events
| Event | Data | Description |
|---|
BANK_OPEN | playerId, bankId, playerPosition | Player opened bank |
BANK_CLOSE | playerId, bankId | Player closed bank |
BANK_DEPOSIT | playerId, itemId, quantity | Depositing item |
BANK_WITHDRAW | playerId, itemId, quantity | Withdrawing item |
BANK_DEPOSIT_ALL | playerId, bankId | Deposit entire inventory |
BANK_DEPOSIT_SUCCESS | playerId, itemId, quantity, bankId | Deposit confirmed |
Placeholder System
Placeholders remember item positions when items are withdrawn, maintaining bank organization.
Placeholder Operations
// Toggle placeholder mode (from BankPanel UI)
world.network.send("bankTogglePlaceholder", {
playerId: player.id,
enabled: true, // Always set placeholders when withdrawing
});
// Release all placeholders (clear remembered positions)
world.network.send("bankReleaseAllPlaceholders", {
playerId: player.id,
});
Two-Phase Slot Update
When releasing all placeholders, the system uses a two-phase approach to prevent database unique constraint violations:
// From packages/server/src/systems/ServerNetwork/handlers/bank/placeholders.ts
// Phase 1: Add large offset to move all slots far from target range
await tx.execute(
sql`UPDATE bank_storage
SET slot = slot + ${SLOT_OFFSET_TEMP}
WHERE "playerId" = ${playerId} AND "tabIndex" = ${tabIndex}`
);
// Phase 2: Renumber slots sequentially using ROW_NUMBER
// Source slots are now ≥1000, target slots are 0-N, no overlap possible
await tx.execute(
sql`UPDATE bank_storage
SET slot = subq.new_slot
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY slot) - 1 as new_slot
FROM bank_storage
WHERE "playerId" = ${playerId} AND "tabIndex" = ${tabIndex}
) as subq
WHERE bank_storage.id = subq.id`
);
Why Two-Phase? PostgreSQL doesn’t guarantee UPDATE execution order. Direct renumbering could cause temporary slot collisions (e.g., row A updates slot 10→6 before row B updates slot 6→3). The two-phase approach eliminates this by ensuring source and target ranges never overlap.
Database Persistence
Banks are persisted via BankRepository:
// Database tables
// - bank_storage: Stores bank items (playerId, bankId, itemId, quantity, slot, tabIndex)
// - Unique constraint: (playerId, tabIndex, slot)
// - bank_tabs: Stores tab configuration (playerId, tabIndex, iconItemId)
// Loaded on player login, saved on bank operations
// Server handles persistence via network packets with transaction safety
Client UI
The BankPanel.tsx component displays:
- Grid view of all bank items
- Tab navigation (9 tabs)
- Search/filter functionality
- Deposit all button
- Withdraw-X functionality
- Placeholder toggle
// BankPanel state
const [bankData, setBankData] = useState<{
visible: boolean;
items: Array<{ itemId: string; quantity: number; slot: number; tabIndex: number }>;
tabs: Array<{ tabIndex: number; iconItemId: string | null }>;
alwaysSetPlaceholder: boolean;
maxSlots: number;
bankId: string;
} | null>(null);
Placeholder System
Two-Phase Slot Updates
The bank system uses a two-phase update to prevent unique constraint violations when releasing all placeholders:
// From bank/placeholders.ts
export async function releaseAllPlaceholders(
playerId: string,
bankId: string,
repository: BankRepository,
): Promise<void> {
// PHASE 1: Set all placeholders to temporary slot -1
await repository.updatePlaceholdersToTempSlot(playerId, bankId);
// PHASE 2: Delete all items with slot -1
await repository.deleteTempSlotItems(playerId, bankId);
}
This prevents the error:
duplicate key value violates unique constraint "bank_storage_player_id_bank_id_slot_unique"
Placeholder Toggle
Players can toggle placeholder mode:
// When enabled, withdrawing last item leaves a placeholder
if (alwaysSetPlaceholder && bankItem.quantity === withdrawQuantity) {
// Replace with placeholder instead of deleting
bankItem.itemId = BANKING_CONSTANTS.PLACEHOLDER_ITEM_ID;
bankItem.quantity = 0;
bankItem.isPlaceholder = true;
}