Resources config & generate

The generate CLI command reads a dubhe.config.ts file and generates typed Move modules for all storage definitions. It eliminates handwritten storage boilerplate and ensures every on-chain read and write is type-safe.

Place a dubhe.config.ts at the root of your contracts directory:

import { defineConfig } from '@0xobelisk/sui-common';
 
export const dubheConfig = defineConfig({
  name: 'my_game',
  description: 'Example on-chain game',
  resources: {}
});

Run the generator:

dubhe generate

This creates a src/<name>/sources/codegen/ directory with all generated Move modules.


Full config type

type DubheConfig = {
  name: string; // Move package name prefix
  description: string;
  enums?: Record<string, string[]>;
  resources?: Record<string, Component | MoveType>;
  objects?: Record<string, ObjectConfig>; // DApp-owned named shared objects
  permits?: Record<string, PermitConfig>; // ScenePermit participant management
  scenes?: Record<string, SceneConfig>; // Multi-user scene shared objects
  errors?: Record<string, ErrorEntry>;
};

resources — per-user and global data

All data accessed frequently (hot-path writes) goes in resources. Each entry generates one Move module under sources/codegen/resources/<name>.move.

Storage targets

FlagGenerated storageUse case
global: trueDappStorage (one per DApp)Server config, leaderboards, aggregate counters
(default)UserStorage (one per user per DApp)Player stats, inventory, progress

Performance: DappStorage is a single shared object — transactions that write to any global resource are serialized. Reserve global: true for infrequently updated data.

Base patterns

Pattern 1 — Global singleton

resources: {
  server_config: {
    global: true,
    fields: { max_players: 'u64', paused: 'bool' }
  }
}

Generated API uses dapp_storage instead of user_storage.

Pattern 2 — User single value (shorthand)

resources: {
  level:  'u32',
  health: 'u64',
}

Pattern 3 — User multi-field record

resources: {
  stats: {
    fields: { attack: 'u32', hp: 'u32', defense: 'u32' }
  }
}
// Generates: Stats struct, get/set/get_struct/set_struct, per-field accessors

Pattern 4 — Keyed record

resources: {
  inventory: {
    fields: { item_id: 'u32', quantity: 'u32' },
    keys: ['item_id']
  }
}
// Generated: has/get/set/delete keyed by item_id
// Also generated: mint(user_storage, item_id, quantity, ctx) — calls ensure_has_not first

Any resource with keys automatically gets a mint function that calls ensure_has_not before writing, preventing silent overwrites.

Pattern 5 — Offchain (event-only)

resources: {
  battle_log: {
    offchain: true,
    fields: { monster: 'address', result: 'bool', damage: 'u32' },
    keys: ['monster']
  }
}
// Only set is generated — data is indexed off-chain via events

offchain: true writes do not persist state. This saves gas when you only need the indexer to see the data, not the contract itself.

Privacy note: offchain: true provides no privacy. Transaction calldata is fully visible on-chain to anyone.


Resource annotations

Annotations extend any Component with additional generated functions. They can be combined as long as they don’t conflict (the generator validates combinations).

All annotation-generated functions are public(package) — you must call them from your own system functions where you can add guards and custom logic.

fungible: true — safe add/sub

Generates add and sub in addition to set. Requires exactly one numeric value field.

resources: {
  gold: {
    fields: { amount: 'u64' },
    fungible: true,
  }
}

Generated:

public(package) fun add(user_storage: &mut UserStorage, amount: u64, ctx: &mut TxContext)
public(package) fun sub(user_storage: &mut UserStorage, amount: u64, ctx: &mut TxContext)
// sub aborts with EInsufficientAmount if amount > current balance

reactive: true — cross-user writes

Generates set_reactive and per-field set_<field>_reactive variants that write to another user’s UserStorage. Requires a ScenePermit for authorization — callers must hold a valid permit covering both the writer and the target.

resources: {
  hp: {
    fields: { current: 'u64', max: 'u64' },
    reactive: true,
  }
}

Generated:

// Write to another user's hp (full record)
public(package) fun set_reactive(
    scene_id: &sui::object::UID,
    meta:     &dubhe::dapp_service::PermitMetadata,
    from:     &mut UserStorage,   // writer (must be in permit)
    target:   &mut UserStorage,   // target (must be in permit)
    current: u64,
    max:     u64,
    ctx:     &mut TxContext,
)
 
// Per-field variants
public(package) fun set_current_reactive(scene_id, meta, from, target, current, ctx)
public(package) fun set_max_reactive(scene_id, meta, from, target, max, ctx)

The framework verifies that both from.canonical_owner and target.canonical_owner are participants of the permit before allowing the write. See the permits section for how to create permits.

transferable: true — cross-storage transfers

Generates transfer_user_to_<obj> and transfer_<obj>_to_user pairs for each objects or scenes entry that declares accepts: ['<resourceName>'].

resources: {
  gold: { fields: { amount: 'u64' }, fungible: true, transferable: true },
  weapon: {
    fields: { item_id: 'u64', damage: 'u32' },
    keys: ['item_id'],
    transferable: true,
  }
}
objects: {
  guild: { fields: { level: 'u32' }, accepts: ['gold', 'weapon'] }
}

Generated in gold.move:

public(package) fun transfer_user_to_guild(user, target, amount, ctx)
public(package) fun transfer_guild_to_user(source, user, amount, ctx)

Generated in weapon.move (keyed — transfers the specific item):

public(package) fun transfer_user_to_guild(user, target, item_id, ctx)
public(package) fun transfer_guild_to_user(source, user, item_id, ctx)

User → User direct transfers are intentionally not generated to prevent griefing. Route player-to-player transfers through a DApp-controlled intermediate storage.

listable: true — built-in marketplace

Generates list, buy, cancel_listing, and expire_listing helpers. Works with both fungible and keyed resources.

resources: {
  weapon: {
    fields: { item_id: 'u64', damage: 'u32' },
    keys: ['item_id'],
    listable: true,
  }
}

Generated (keyed version):

// Seller removes item from UserStorage and creates a Listing shared object
public(package) fun list<CoinType>(
    user_storage: &mut UserStorage,
    item_id:      u64,
    price:        u64,
    listed_until: std::option::Option<u64>,
    ctx:          &mut TxContext,
)
 
// Buyer pays and receives item into their UserStorage
public(package) fun buy<CoinType>(
    dh:           &dubhe::dapp_service::DappHub,
    dapp_storage: &mut DappStorage,
    listing:      dubhe::dapp_service::Listing<CoinType>,
    user_storage: &mut UserStorage,
    payment:      sui::coin::Coin<CoinType>,
    ctx:          &mut TxContext,
): sui::coin::Coin<CoinType>  // returns change
 
// Seller cancels and reclaims item
public(package) fun cancel_listing<CoinType>(listing, user_storage, ctx)
 
// Anyone cleans up an expired listing; item returns to seller
public(package) fun expire_listing<CoinType>(listing, user_storage, ctx)

The CoinType type parameter lets buyers pay with any accepted token. Listing is a framework-provided shared object; Move’s linear type guarantees the item exists in exactly one place at a time.


objects — DApp-owned named shared objects

objects declares named entities controlled by the DApp — for example, a guild, a boss, or a seasonal reward pool. Each entry generates a <key>Storage typed Shared Object module (ObjectStorage<MarkerType>).

objects: {
  guild: {
    fields:    { level: 'u32', name: 'String' },
    accepts:   ['gold', 'weapon'],   // resources stored in this object
    acceptsFrom: ['dungeon_run'],    // objects/scenes that can transfer into this
    adminOnly: true,                 // only DApp admin can call create_guild
  }
}

fields

Object metadata fields. Generates get_<field> (public) and set_<field> (public(package)) accessors on ObjectStorage<Guild>.

accepts: [...]

Lists resources (from the resources section) that can be stored inside this object. For each accepted resource the object module gets bag accessors:

  • Fungible resource: get_<resource>, add_<resource>, sub_<resource>
  • Keyed resource: has_<resource>, get_<resource>_data, set_<resource>_data (prevents duplicate item_id with EDuplicateItemId), remove_<resource>_data

acceptsFrom: [...]

Lists other objects or scenes whose resources can be moved into this object. Generates transfer_<source>_to_<dest>_<resource> functions (in the destination module, public(package)). The transferable resources are the intersection of source.accepts and this.accepts.

adminOnly: true

Inserts an admin check at the top of create_<obj>:

assert!(ctx.sender() == dubhe::dapp_service::dapp_admin(dapp_storage), ENoPermission);

Generated lifecycle functions

// Creates and immediately shares the ObjectStorage
public fun create_<obj>(
    dapp_storage: &mut DappStorage,
    entity_id:    vector<u8>,       // unique identifier within this object type
    ctx:          &mut TxContext,
)
 
// Destroys the ObjectStorage (bag must be empty)
public fun destroy_<obj>(
    dapp_storage: &mut DappStorage,
    storage:      ObjectStorage<Guild>,
    ctx:          &TxContext,
)
 
// ID helpers
public fun entity_id(storage: &ObjectStorage<Guild>): vector<u8>
public fun assert_guild_id(storage: &ObjectStorage<Guild>, expected: vector<u8>)

The entity_id uniqueness is enforced by the framework: registering the same (type_tag, entity_id) pair twice in DappStorage aborts with an error.


permits — ScenePermit objects

permits declares participant management objects. Each entry generates a ScenePermit<MarkerType> shared object that tracks a set of participants and an optional expiry. Permits are the authorization layer for reactive writes and permit-gated scenes.

permits: {
  pvp_match: {
  } // PermitConfig is currently empty; reserved for future options
}

Generated module (pvp_match.move) exposes:

// Create a permit with a fixed participant list (returns unshared)
public(package) fun new_pvp_match(dapp_storage, participants, expires_at, max_participants, ctx): ScenePermit<PvpMatch>
// Create and immediately share
public(package) fun create_pvp_match(dapp_storage, participants, expires_at, max_participants, ctx)
 
// Create a permit with open invitations (invitees must accept)
public(package) fun new_pvp_match_with_invitations(dapp_storage, invitees, invites_expire_at, scene_expires_at, max_participants, ctx): ScenePermit<PvpMatch>
public(package) fun create_pvp_match_with_invitations(dapp_storage, invitees, invites_expire_at, scene_expires_at, max_participants, ctx)
 
// Share an unshared permit (use after new_pvp_match)
public(package) fun share_pvp_match(permit: ScenePermit<PvpMatch>)
 
// Invitee accepts their invitation
public(package) fun accept_pvp_match(permit, ctx)
 
// Anyone joins an open permit (no prior invitation required)
public(package) fun join_pvp_match(permit, ctx)
 
// Participant leaves
public(package) fun leave_pvp_match(permit, ctx)
 
// Destroy an expired permit
public(package) fun expire_pvp_match(permit, ctx)

Also available:

public fun meta(permit)              // access PermitMetadata (participants, expiry)
public fun is_active(permit, now_ms) // whether permit has not expired
public fun is_participant(permit, addr)

scenes — multi-user scene shared objects

scenes declares multi-user shared objects for time-bounded interactions — for example, a PvP match or a dungeon run. Each entry generates a <key>Storage module (SceneStorage<MarkerType>).

Every scene must specify how writes to its fields are authorized:

type SceneAuthorization =
  | { kind: 'permit'; permit: string } // writes require holding a ScenePermit
  | { kind: 'system' }; // writes are callable by system functions directly
scenes: {
  dungeon_run: {
    fields:        { floor: 'u32', boss_id: 'u64' },
    authorization: { kind: 'permit', permit: 'dungeon_permit' },
    accepts:       ['gold', 'loot'],
    acceptsFrom:   ['pvp_match'],
  },
  leaderboard_snapshot: {
    fields:        { snapshot_time: 'u64' },
    authorization: { kind: 'system' },
  }
}

authorization (required)

  • { kind: 'permit', permit: '<permitName>' }set_<field> requires a &ScenePermit<MarkerType> argument and a &TxContext. The framework verifies the caller is an active participant of the permit before allowing the write.
  • { kind: 'system' }set_<field> takes only (storage, value) with no permit or context; it is callable directly by any function in your package.

fields, accepts, acceptsFrom

Same semantics as objects. Generated bag accessors and transfer functions follow the same patterns. For permit-authorized scenes, bag write accessors (add_<resource>, sub_<resource>, set_<resource>_data, remove_<resource>_data) also require the permit and ctx parameters.

Scene lifecycle

The framework generates lifecycle helpers in the scene module. The exact function names depend on the authorization kind:

{ kind: 'system' } — system-authorized scenes:

// Returns an unshared SceneStorage (suitable for in-tx population before sharing)
public(package) fun new_<scene>_system(dapp_storage, ctx): SceneStorage<Marker>
 
// Creates and immediately shares the SceneStorage
public(package) fun create_<scene>_system(dapp_storage, ctx)
 
// Share an unshared SceneStorage returned by new_<scene>_system
public(package) fun share_<scene>(storage: SceneStorage<Marker>)
 
// Destroy a SceneStorage — the bag must be empty
public(package) fun destroy_<scene>(storage: SceneStorage<Marker>)

{ kind: 'permit', permit: '...' } — permit-authorized scenes:

// Returns an unshared SceneStorage; the caller must hold a valid permit
public(package) fun new_<scene>_with_permit(dapp_storage, permit, ctx): SceneStorage<Marker>
 
// Creates and immediately shares the SceneStorage
public(package) fun create_<scene>_with_permit(dapp_storage, permit, ctx)
 
// Share / destroy (same as system-authorized)
public(package) fun share_<scene>(storage: SceneStorage<Marker>)
public(package) fun destroy_<scene>(storage: SceneStorage<Marker>)

enums — custom enum types

Define shared enum types usable as field or key types across any resource.

enums: {
  Direction: ['North', 'East', 'South', 'West'],
  Status:    ['Idle', 'Fighting', 'Dead']
}

Each enum generates a module with constructors, matchers, and BCS encode/decode helpers:

use mygame::direction::{Self, Direction};
 
let dir = direction::new_north();
direction::is_east(&dir); // false

errors — custom abort messages

Custom abort constants with readable messages:

errors: {
  player_not_found:   'Player does not exist',
  not_enough_health:  'HP is too low to perform this action',
}

Generated:

use mygame::error;
 
error::player_not_found(player::has(user_storage));  // aborts if false
error::not_enough_health(hero_hp > 0);

Complete example

import { defineConfig } from '@0xobelisk/sui-common';
 
export const dubheConfig = defineConfig({
  name: 'mygame',
  description: 'An on-chain RPG with guilds and PvP',
 
  enums: {
    Class: ['Warrior', 'Mage', 'Rogue']
  },
 
  resources: {
    // Global config — one per DApp
    server_config: {
      global: true,
      fields: { max_level: 'u32', season_id: 'u64' }
    },
 
    // Per-player stats
    stats: {
      fields: { class: 'Class', hp: 'u64', level: 'u32' }
    },
 
    // Fungible gold — safe add/sub, transferable to guild
    gold: {
      fields: { amount: 'u64' },
      fungible: true,
      transferable: true
    },
 
    // Weapon items — keyed by item_id, with marketplace
    weapon: {
      fields: { item_id: 'u64', damage: 'u32', rarity: 'u8' },
      keys: ['item_id'],
      transferable: true,
      listable: true
    },
 
    // HP with reactive cross-user writes (for combat)
    combat_hp: {
      fields: { current: 'u64', max: 'u64' },
      reactive: true
    },
 
    // Position — offchain event only, saves gas
    position: {
      fields: { x: 'u32', y: 'u32' },
      offchain: true
    }
  },
 
  objects: {
    guild: {
      fields: { level: 'u32', name: 'String' },
      accepts: ['gold', 'weapon'],
      adminOnly: true
    }
  },
 
  permits: {
    pvp_permit: {}
  },
 
  scenes: {
    pvp_match: {
      fields: { round: 'u32', map_id: 'u64' },
      authorization: { kind: 'permit', permit: 'pvp_permit' },
      accepts: ['weapon']
    }
  },
 
  errors: {
    player_not_found: 'Player does not exist',
    game_not_started: 'The game has not started yet',
    insufficient_gold: 'Not enough gold'
  }
});