Skip to main content

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

EventDataDescription
BANK_OPENplayerId, bankId, playerPositionPlayer opened bank
BANK_CLOSEplayerId, bankIdPlayer closed bank
BANK_DEPOSITplayerId, itemId, quantityDepositing item
BANK_WITHDRAWplayerId, itemId, quantityWithdrawing item
BANK_DEPOSIT_ALLplayerId, bankIdDeposit entire inventory
BANK_DEPOSIT_SUCCESSplayerId, itemId, quantity, bankIdDeposit 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;
}