Skip to content

Bank & Storage System

This document outlines the bank/storage system for Rent Earth, providing players with additional item storage beyond their inventory.

The bank is a secure storage system accessible via Bank NPCs in towns. It provides:

  • Extra storage beyond the 40-100 inventory slots
  • Safe storage - items in bank are never lost on death
  • Account-wide potential - can share between characters (optional)
  • Expandable - purchase additional tabs with gold

PostgreSQL (Supabase) Axum Game Server Unity Client
──────────────────── ──────────────── ────────────
Bank slot arrays AUTHORITATIVE Display only
- character_bank table - Deposit/withdraw - Show bank UI
- items table - Validate ownership - Drag & drop
- Tab unlocks - Send requests

ComponentStatusLocation
BankConfig✅ CompleteAssets/Scripts/ScriptableObjects/BankConfig.cs
BankState✅ CompleteAssets/Scripts/State/BankState.cs
BankService✅ CompleteAssets/Scripts/Services/BankService.cs
MessagePipe Events✅ CompleteRootLifetimeScope.cs
BankConfig.asset✅ CompleteResources/ScriptableObjects/Oracle/BankConfig.asset
Bank UI🔲 PlannedUI Toolkit implementation
Server Validation🔲 PlannedAxum game server
Database Schema🔲 PlannedSupabase migration
Proto Messages🔲 Plannedsnapshot.proto extension

┌─────────────────────────────────────────────────────────────────┐
│ BANK STORAGE │
├─────────────────────────────────────────────────────────────────┤
│ TAB 1 (Default) TAB 2 (Unlockable) TAB 3 (Unlockable) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 28 slots │ │ 28 slots │ │ 28 slots │ │
│ │ (7x4 grid) │ │ (7x4 grid) │ │ (7x4 grid) │ │
│ │ │ │ LOCKED │ │ LOCKED │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ TAB 4 (Unlockable) TAB 5 (Unlockable) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 28 slots │ │ 28 slots │ Total: 140 slots │
│ │ LOCKED │ │ LOCKED │ (5 tabs × 28) │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
TabSlotsDefaultUnlock Cost
128Yes (free)-
228No5,000 gold
328No15,000 gold
428No50,000 gold
528No150,000 gold
TabCostCumulative
25,0005,000
315,00020,000
450,00070,000
5150,000220,000

Configuration asset for bank system parameters. Located at Resources/ScriptableObjects/Oracle/BankConfig.asset.

[CreateAssetMenu(fileName = "BankConfig", menuName = "RentEarth/Bank/Bank Config")]
public class BankConfig : ScriptableObject
{
// Bank Structure
public int slotsPerTab = 28; // 7x4 grid
public int maxTabs = 5;
public int defaultUnlockedTabs = 1;
// Tab Unlock Costs
public long tab2UnlockCost = 5000;
public long tab3UnlockCost = 15000;
public long tab4UnlockCost = 50000;
public long tab5UnlockCost = 150000;
// Interaction Settings
public float interactionRange = 5f; // meters
public bool requireNpcProximity = true;
// Gold Storage
public bool enableGoldStorage = true;
public float depositFeePercent = 0f;
public float withdrawFeePercent = 0f;
// Item Restrictions
public bool allowQuestItems = false;
public bool allowSoulboundItems = true;
public bool requireUnequipped = true;
// UI Grid
public int gridColumns = 7;
public int gridRows = 4;
}

Thread-safe reactive state for bank storage. Uses SynchronizedReactiveProperty for UI binding and ReaderWriterLockSlim for thread safety.

public class BankState : IAsyncStartable, IDisposable
{
// Reactive properties for UI binding
public ReadOnlyReactiveProperty<int> UnlockedTabCount { get; }
public ReadOnlyReactiveProperty<long> StoredGold { get; }
public ReadOnlyReactiveProperty<bool> IsBankOpen { get; }
// Thread-safe slot access
public BankSlotData GetSlot(int tabIndex, int slotIndex);
public BankSlotData[] GetTabSlots(int tabIndex);
public (int tabIndex, int slotIndex) FindFirstEmptySlot();
// Server response handlers (for future integration)
public void ApplyFullState(int tabsUnlocked, long storedGold, BankSlotData[][] tabSlots);
public void ApplyDelta(int tabIndex, (int slot, BankSlotData data)[] changes, long? newStoredGold);
}

Clean API for UI and game systems to interact with the bank.

public class BankService
{
// Bank Access
public void OpenBank(Guid npcEntityId = default);
public void CloseBank();
public void ToggleBank(Guid npcEntityId = default);
public bool IsBankOpen { get; }
// Item Operations
public void DepositItem(int inventorySlot, int bankTab, int bankSlot = -1, int quantity = 0);
public void DepositItemToFirstEmpty(int inventorySlot, int quantity = 0);
public void WithdrawItem(int bankTab, int bankSlot, int inventorySlot = -1, int quantity = 0);
public void MoveItem(int fromTab, int fromSlot, int toTab, int toSlot);
// Gold Operations
public void DepositGold(long amount);
public void DepositAllGold();
public void WithdrawGold(long amount);
public void WithdrawAllGold();
public long StoredGold { get; }
// Tab Operations
public bool TryUnlockNextTab();
public long GetNextUnlockCost();
public int UnlockedTabCount { get; }
public bool IsTabUnlocked(int tabIndex);
// Slot Queries
public BankSlotData GetSlot(int tabIndex, int slotIndex);
public int TotalAvailableSlots { get; }
public int TotalUsedSlots { get; }
public int TotalEmptySlots { get; }
}

Bank events registered in RootLifetimeScope:

// Bank request messages (client -> server)
builder.RegisterMessageBroker<OpenBankRequest>(options);
builder.RegisterMessageBroker<DepositItemRequest>(options);
builder.RegisterMessageBroker<WithdrawItemRequest>(options);
builder.RegisterMessageBroker<MoveBankItemRequest>(options);
builder.RegisterMessageBroker<DepositGoldRequest>(options);
builder.RegisterMessageBroker<WithdrawGoldRequest>(options);
builder.RegisterMessageBroker<UnlockBankTabRequest>(options);
// Bank state messages (for UI updates)
builder.RegisterMessageBroker<BankChangedMessage>(options);
builder.RegisterMessageBroker<BankUIStateMessage>(options);

CREATE TABLE IF NOT EXISTS rentearth.character_bank (
character_id uuid PRIMARY KEY REFERENCES rentearth.character(id) ON DELETE CASCADE,
-- Bank tabs (each tab = 28 slots)
-- NULL in array = empty slot, UUID = item reference
tab_1 uuid[] NOT NULL DEFAULT array_fill(NULL::uuid, ARRAY[28]),
tab_2 uuid[] NOT NULL DEFAULT array_fill(NULL::uuid, ARRAY[28]),
tab_3 uuid[] NOT NULL DEFAULT array_fill(NULL::uuid, ARRAY[28]),
tab_4 uuid[] NOT NULL DEFAULT array_fill(NULL::uuid, ARRAY[28]),
tab_5 uuid[] NOT NULL DEFAULT array_fill(NULL::uuid, ARRAY[28]),
-- Which tabs are unlocked
tabs_unlocked smallint NOT NULL DEFAULT 1 CHECK (tabs_unlocked BETWEEN 1 AND 5),
-- Stored gold (separate from inventory gold)
stored_gold bigint NOT NULL DEFAULT 0 CHECK (stored_gold >= 0),
updated_at timestamptz NOT NULL DEFAULT timezone('utc', now())
);
-- Function to get total bank slots
CREATE OR REPLACE FUNCTION rentearth.bank_total_slots(tabs_unlocked smallint)
RETURNS integer AS $$
SELECT tabs_unlocked * 28;
$$ LANGUAGE sql IMMUTABLE;
-- Function to count used bank slots
CREATE OR REPLACE FUNCTION rentearth.bank_used_slots(
tab_1 uuid[], tab_2 uuid[], tab_3 uuid[], tab_4 uuid[], tab_5 uuid[],
tabs_unlocked smallint
)
RETURNS integer AS $$
DECLARE
total integer := 0;
BEGIN
SELECT count(*) INTO total FROM unnest(tab_1) AS s WHERE s IS NOT NULL;
IF tabs_unlocked >= 2 THEN
total := total + (SELECT count(*) FROM unnest(tab_2) AS s WHERE s IS NOT NULL);
END IF;
IF tabs_unlocked >= 3 THEN
total := total + (SELECT count(*) FROM unnest(tab_3) AS s WHERE s IS NOT NULL);
END IF;
IF tabs_unlocked >= 4 THEN
total := total + (SELECT count(*) FROM unnest(tab_4) AS s WHERE s IS NOT NULL);
END IF;
IF tabs_unlocked >= 5 THEN
total := total + (SELECT count(*) FROM unnest(tab_5) AS s WHERE s IS NOT NULL);
END IF;
RETURN total;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Add bank restriction flag to item definitions
ALTER TABLE rentearth.item_definitions
ADD COLUMN can_bank boolean NOT NULL DEFAULT true;

// Full bank state sent to client when opening bank
message CharacterBankState {
// Tab data (only unlocked tabs have items)
repeated BankTab tabs = 1;
// How many tabs unlocked
uint32 tabs_unlocked = 2;
// Gold stored in bank
int64 stored_gold = 3;
// Full item data for items in bank
repeated ItemInstance items = 4;
}
message BankTab {
uint32 tab_index = 1; // 0-4
repeated bytes slots = 2; // 28 UUIDs (empty bytes = empty slot)
bool is_locked = 3;
}
message BankAction {
oneof action {
OpenBankRequest open = 1;
DepositItemRequest deposit = 2;
WithdrawItemRequest withdraw = 3;
MoveBankItemRequest move = 4;
DepositGoldRequest deposit_gold = 5;
WithdrawGoldRequest withdraw_gold = 6;
UnlockTabRequest unlock_tab = 7;
}
}
// Request to open bank (must be near Bank NPC)
message OpenBankRequest {
bytes npc_entity_id = 1; // Bank NPC being interacted with
}
// Deposit item from inventory to bank
message DepositItemRequest {
int32 inventory_slot = 1;
int32 bank_tab = 2; // 0-4
int32 bank_slot = 3; // 0-27 within tab (-1 = first empty)
uint32 quantity = 4; // For stacks (0 = all)
}
// Withdraw item from bank to inventory
message WithdrawItemRequest {
int32 bank_tab = 1;
int32 bank_slot = 2;
int32 inventory_slot = 3; // -1 = first empty
uint32 quantity = 4; // For stacks (0 = all)
}
// Move item within bank
message MoveBankItemRequest {
int32 from_tab = 1;
int32 from_slot = 2;
int32 to_tab = 3;
int32 to_slot = 4;
}
// Deposit/withdraw gold
message DepositGoldRequest {
int64 amount = 1; // 0 = all
}
message WithdrawGoldRequest {
int64 amount = 1; // 0 = all
}
// Unlock next bank tab
message UnlockTabRequest {
// Cost determined by server based on current tabs_unlocked
}
message BankResponse {
bool success = 1;
string error = 2;
oneof result {
CharacterBankState bank_state = 3; // Full state after open/unlock
BankDelta delta = 4; // Partial update after deposit/withdraw
UnlockTabResult unlock_result = 5;
}
}
message BankDelta {
int32 tab = 1;
repeated BankSlotChange changes = 2;
int64 stored_gold = 3; // Updated bank gold
int64 inventory_gold = 4; // Updated inventory gold
}
message BankSlotChange {
int32 slot = 1;
bytes item_id = 2; // Empty = slot cleared
}
message UnlockTabResult {
uint32 new_tabs_unlocked = 1;
int64 gold_spent = 2;
}

Players must be near a Bank NPC to access their bank:

  1. Player interacts with Bank NPC (press E)
  2. Client sends OpenBankRequest with NPC entity ID
  3. Server validates player is in range
  4. Server sends CharacterBankState
  5. Client opens bank UI
const BANK_INTERACTION_RANGE: f32 = 5.0; // meters
fn validate_bank_access(player_pos: Vec3, npc_pos: Vec3) -> bool {
player_pos.distance(npc_pos) <= BANK_INTERACTION_RANGE
}

1. Client sends DepositItemRequest
2. Server validates:
- Item exists in inventory
- Target tab is unlocked
- Target slot is empty (or can stack)
- Item is not bound/quest item (optional restriction)
3. Server updates:
- items.container_type = 'bank'
- items.container_slot = bank_slot
- character_inventory.slots[inv_slot] = NULL
- character_bank.tab_X[bank_slot] = item_id
4. Server sends BankDelta + InventoryDelta
1. Client sends WithdrawItemRequest
2. Server validates:
- Item exists in bank
- Inventory has space
3. Server updates:
- items.container_type = 'inventory'
- items.container_slot = inv_slot
- character_bank.tab_X[bank_slot] = NULL
- character_inventory.slots[inv_slot] = item_id
4. Server sends BankDelta + InventoryDelta

Players can store gold in the bank for safekeeping:

  • Bank gold is separate from inventory gold
  • Bank gold is never lost on death
  • No fees for deposit/withdraw (configurable)
// Example gold deposit
DepositGoldRequest { amount: 10000 }
// Server response includes updated balances
BankDelta {
stored_gold: 15000, // Bank now has 15k
inventory_gold: 5000 // Inventory now has 5k
}

  1. Proximity: Must be near Bank NPC (configurable in BankConfig)
  2. Tab Access: Can only use unlocked tabs
  3. Slot Bounds: Slot index 0-27 within each tab
  4. Ownership: Item must belong to character
  5. Space Check: Target location has room
  6. Stack Rules: Same as inventory stacking
  7. Binding: Some items may be “cannot bank” (can_bank = false)

  • Quest items: Must stay in inventory (configurable)
  • Soulbound: Depends on game design (configurable)
  • Currently equipped: Must unequip first (configurable)

┌─────────────────────────────────────────────────────────────────┐
│ BANK [X] Close │
├─────────────────────────────────────────────────────────────────┤
│ [Tab 1] [Tab 2] [Tab 3] [Tab 4] [Tab 5] │
│ 🔒 🔒 🔒 🔒 │
├─────────────────────────────────────────────────────────────────┤
│ ┌────┬────┬────┬────┬────┬────┬────┐ │
│ │ │ │ │ │ │ │ │ Row 1 │
│ ├────┼────┼────┼────┼────┼────┼────┤ │
│ │ │ │ │ │ │ │ │ Row 2 │
│ ├────┼────┼────┼────┼────┼────┼────┤ │
│ │ │ │ │ │ │ │ │ Row 3 │
│ ├────┼────┼────┼────┼────┼────┼────┤ │
│ │ │ │ │ │ │ │ │ Row 4 │
│ └────┴────┴────┴────┴────┴────┴────┘ │
│ │
│ Gold Stored: 15,000 g [Deposit All] [Withdraw All] │
│ │
│ [Unlock Next Tab: 5,000 gold] │
└─────────────────────────────────────────────────────────────────┘

When submitting an item to auction from bank:

  1. Item must be in bank
  2. Server moves item: container_type = 'auction'
  3. If auction expires/cancelled, item returns to bank (not inventory)
message SubmitToAuctionRequest {
oneof source {
int32 inventory_slot = 1;
BankSlotRef bank_slot = 2;
}
int64 starting_bid = 3;
int64 buyout_price = 4;
int32 duration_hours = 5; // 12, 24, or 48
}
message BankSlotRef {
int32 tab = 1;
int32 slot = 2;
}

For shared storage between characters on same account:

CREATE TABLE IF NOT EXISTS rentearth.account_storage (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
slots uuid[] NOT NULL DEFAULT array_fill(NULL::uuid, ARRAY[28]),
updated_at timestamptz NOT NULL DEFAULT timezone('utc', now())
);

Items in account storage would have container_type = 'account_storage'.


  • Create BankConfig ScriptableObject
  • Create BankState with reactive properties
  • Create BankService API layer
  • Register MessagePipe events
  • Create BankConfig.asset with default values
  • Create Bank UI with UI Toolkit
  • Implement tab switching
  • Implement slot grid display
  • Add drag-and-drop support
  • Add gold display and deposit/withdraw buttons
  • Add unlock tab button with cost display
  • Add proto definitions to snapshot.proto
  • Implement bank handlers in Axum server
  • Add NPC proximity validation
  • Connect client requests to server
  • Create character_bank table migration
  • Add can_bank column to item_definitions
  • Implement database operations in server
  • Add bank state to character load
  • Add Bank NPC entity type
  • Add bank interaction animation
  • Add sound effects for deposit/withdraw
  • Add toast notifications for errors

QuestionNotes
Account-wide storageFuture feature - share between alts
Bank feesFree for now, could add gold sink later
Remote bankingAllow bank access from anywhere? (premium feature?)
Guild bankShared storage for guilds

FilePurpose
Assets/Scripts/ScriptableObjects/BankConfig.csBank configuration ScriptableObject
Assets/Scripts/State/BankState.csReactive bank state with thread safety
Assets/Scripts/Services/BankService.csBank API for UI and game systems
Assets/Scripts/LifetimeScope/RootLifetimeScope.csMessagePipe registration
Resources/ScriptableObjects/Oracle/BankConfig.assetDefault configuration values