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 generateThis 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
| Flag | Generated storage | Use case |
|---|---|---|
global: true | DappStorage (one per DApp) | Server config, leaderboards, aggregate counters |
| (default) | UserStorage (one per user per DApp) | Player stats, inventory, progress |
Performance:
DappStorageis a single shared object — transactions that write to any global resource are serialized. Reserveglobal: truefor 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 accessorsPattern 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 firstAny 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 eventsoffchain: 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: trueprovides 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 balancereactive: 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 → Userdirect 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 duplicateitem_idwithEDuplicateItemId),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 directlyscenes: {
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); // falseerrors — 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'
}
});