EVM notes: understanding storage slot packing using The Sandbox's ASSET as a real-world example
Why it's important to understand packing and what this can do for Creator-first projects
In this article I’m going to illustrate how storage works at EVM level, using a real-world example.
ASSET is The Sandbox Game’s Creator token. Since the very first generation ASSET tokens were launched on Ethereum mainnet, almost 5 years ago, The Sandbox has been a pioneer of creator-first features. ASSET tokens are a form of user-generated content (UGC) in the form of voxel models.
tl;dr: ASSET uses strategic storage slot packing to generate special ASSET token ids that benefit users in multiple ways in UGC experiences.
Storage fundamentals
Smart contract storage
The Ethereum Virtual Machine enforces a max contract size for deployed bytecode, defined by EIP-170,
MAX_CODE_SIZE:0x6000(2**14 + 2**13)
i.e. 24,576 bytes of deployed runtime bytecode is allowed.
A contract’s storage is modelled as a key–value store. Think of a contract as an ordered ‘list’ of 32 byte keys (slots) beginning at slot 0, where each key is a 32 byte slot index and each value is a 32 byte storage slot. Since the key is 32 bytes (256 bits), the number of possible storage slots is 2^256 slots, astronomically large.
All values are initialised as 0 and zeros are not explicitly stored; only the slots that are actually written to get stored in the state trie.
Mappings can access any slot in the entire 2^256 range.
Example
This contract uses 3 predictable slots:
contract Slots {
uint256 a; // slot 0
bool b; // slot 1 (packed but stored as 1 byte)
uint256 c; // slot 2
}And if we add:
mapping(address => uint256) balances;then every entry for balances[user] is stored at:
slot = keccak256(bytes32(user) . bytes32(mapping_slot))which can land anywhere in the 2^256 space.
More on slots
The Solidity docs give us a great starting point for understanding how smart contract storage layout works.

Taking each bullet in turn.
The first item in a storage slot is stored lower-order aligned.
When Solidity puts the first variable into a slot, it starts at the right-hand side (the least-significant / lower-order bytes). Think of packing items starting from the right edge of a box.
contract Demo1 {
uint128 a; // stored in lower (right) 16 bytes of slot 0
uint128 b; // stored in upper (left) 16 bytes of slot 0
}Value types use only as many bytes as are necessary to store them.
Small data types (like uint8, bool, address) take only the exact amount of space they need. Solidity doesn’t waste the rest of the 32-byte slot.
contract Demo2 {
uint8 a; // uses 1 byte
bool b; // uses 1 byte
uint16 c; // uses 2 bytes
}If a value type does not fit the remaining part of a storage slot, it is stored in the next storage slot.
If the slot doesn’t have enough room left for the next variable, Solidity starts a brand-new slot instead of splitting the variable across multiple slots.
contract Demo3 {
uint256 big; // occupies whole slot 0
uint128 x; // placed in slot 1 (no space left in slot 0)
}Structs and array data always start a new slot and their items are packed tightly according to the rules.
When Solidity sees a struct or array, it always starts it on a fresh slot. Inside that struct / array, Solidity packs the fields according to the usual packing rules (small types packed together, etc.).
contract Demo4 {
struct S { uint128 a; uint128 b; }
S data; // starts at a new slot; a and b packed inside
}Items following strut of array data always start a new storage slot.
Once a struct or array ends, the next variable will also start at a new slot. Solidity never tries to continue packing after a struct/array.
contract Demo5 {
uint8 before;
uint256[] arr; // starts a new slot
uint8 after; // also starts a new slot after arr
}Case study: ASSET token slot packing
Before token standard ERC1155 became final, The Sandbox launched the original ASSET token: a non-standard hybrid token that could be either 1155 or 721 depending on the user interaction.
Let’s dive into this original version.
Now, I mentioned that this token is non-standard. What do I mean by this?
Well, the token follows the interfaces for both ERC1155 and ERC721. In the same contract. It’s a lengthy beast, with minimal docs 😅, and it’s a fun challenge to pick it apart… I certainly wouldn’t recommend writing a contract in this way now.
In this article I am going to focus only on the token id.
First, a couple of notes about how the contract was intended to work.
The contract implements a concept called “extraction” whereby a single copy of an ERC1155 can be replaced with an ERC721. Imagine, you have a sword in a game, originally from a set of, say, 10 identical swords, but something happens and all of a sudden your sword becomes a magical, unique sword. The ERC1155 copy is burned, and a new ERC721 is minted.
ERC1155 with supply 1 is treated as an “NFT”.
The bytes are packed in a way that takes into account various properties of the ASSET token, maximising the utility of its storage slot.
For each ASSET token, we can ascertain the following from its id:
The address of its Creator.
Is it an NFT?
What is its ‘NFT_INDEX’?
What is its ‘URI_ID’?
What is its ‘PACK_ID’?
What is its ‘PACK_INDEX’?
What is its ‘PACK_NUM_FT_TYPES’?
But, how is this done?
An ASSET’s token id is generated in a non-standard way in order to preserve information about the token itself in the id. The rest of this article focuses on how the token id is constructed with each of these pieces of information inside.
Generating a token id
When minting single assets, an ASSET’s tokenId is generated by the internal function generateTokenId which uses a number of constant offsets that we can see at the beginning of the contract.
Now I’m going to step through this function line by line, to explain what each offset does and why. We’ll start at line 184.
I’m following the single mint path, function mint.
Note: function mintMultiple looks different in certain places.
CREATOR_OFFSET_MULTIPLIER
uint256(address) * CREATOR_OFFSET_MULTIPLIERThe creator address is shifted using the offset we saw at the top of the contract;
uint256 private constant CREATOR_OFFSET_MULTIPLIER = uint256(2)**(256 - 160) In other words, CREATOR_OFFSET_MULTIPLIER = uint256(2)**96
Conceptually, this looks like the following:
PUSH 0x60 // shift amount = 96 decimal
SHL // shift uint160 → top 160 bits of 256-bit wordIn hex:
0x 01 00 00 00 00 00 00 00 00 00 00 00 00Then,
uint256(address) casts the address as a uint256.
Let’s use this address:
0x 23 61 8e 81 E3 f5 cd F7 f5 4C 3d 65 f7 FB c0 aB f5 B2 1E 8fuint256(address) gives
0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f i.e. the same as before. But, now it’s been typecast as a type we can apply the offset to.
Offsetting results in a 32byte token id with the creator’s address on the left (20bytes), and CREATOR_OFFSET_MULTIPLIER provides 12bytes of zeros on the right
0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f 00 00 00 00 00 00 00 00 00 00 00 00IS_NFT_OFFSET_MULTIPLIER
(supply == 1 ? uint256(1) * IS_NFT_OFFSET_MULTIPLIER : 0), The idea here is to flip a bit if the supply == 1, otherwise do nothing.
When supply == 1, we treat the ASSET as an NFT, so we need to mark this fact inside the tokenId. The contract does this by shifting a single bit into position using IS_NFT_OFFSET_MULTIPLIER.
If supply == 1:
uint256(1)This is just:
0x01The multiplier:
uint256 internal constant IS_NFT_OFFSET_MULTIPLIER = 2**(256 - 160 - 1);→ shifts this bit 95 places to the left, giving:
0x80 00 00 00 00 00 00 00 00 00 00 00So:
If the ASSET is an NFT (supply == 1), this bit is set.
Otherwise, it’s zero.
This single bit becomes the IS_NFT flag inside the tokenId.
Conceptual EVM representation of IS_NFT = 1«95
PUSH 0x5F // 95 decimal
SHL // compute (1 << 95)
OR // set the bit inside tokenIdPACK_ID
Next, we incorporate the packId.
uint256(packId) * PACK_ID_OFFSET_MULTIPLIERWhere:
PACK_ID_OFFSET_MULTIPLIER = 2**23This shifts the 40-bit packId left by 23 bits so it sits in the middle region of the tokenId.
Conceptual EVM representation
PUSH 0x17 // 23 decimal
SHL // shift packId into bits [62..23]
ORExamples:
packId = 1
→ 0x0000000000000000000000000000000000000000000000000000000000800000
packId = 2
→ 0x0000000000000000000000000000000000000000000000000000000001000000
packId = 1000
→ 0x000000000000000000000000000000000000000000000000000000007D000000
packId = max uint40 (0xffffffffff)
→ 0x000000000000000000000000000000000000000000001FFFFFFF80000000
The position is chosen to leave space:
above for creator address + NFT flag
below for packIndex, uriId, and NFT index
PACK_NUM_FT_TYPES
Now we place the number of FT types inside the pack.
numFTs * PACK_NUM_FT_TYPES_OFFSET_MULTIPLIERWhere:
PACK_NUM_FT_TYPES_OFFSET_MULTIPLIER = 2**11This shifts a 16-bit numFTs up by 11 bits.
Conceptual EVM representation:
PUSH 0x0B // 11 decimal
SHL
ORImportant detail from the design of the contract:
If
supply == 1, thennumFTs = 0If
supply > 1, thennumFTs = 1
meaning,
NFTs store 0 here
FT assets store 0x0800
This gives the system a way to record whether a pack contains fungible types at all.
PACK_INDEX
packIndex is declared as a uint16. Depending on the operation, it can either be 0 or incremented.
In single-mint operations,
packIndex = 0.In multiple-mint operations (
mintMultiple), it increments from zero upward for each item minted within the same packId.
However, the packIndex does not get a full 16-bit allocation inside the tokenId. The ASSET tokenId packs many fields tightly, so the final 11 bits (below the packNumFTTypes section) are used for it.
Conceptual EVM representation
PUSH 0x7FF // 2047 decimal
AND // zero all but lowest 11 bits
OR // combine into tokenIdThus:
packIndex actually fits into 11 bits
Max packIndex stored in the tokenId =
2^11 - 1 = 2047Larger values technically possible as a uint16, but truncated by packing
This explains one of the quirks of the original ASSET token: the visible packIndex value inside the id is not the same as the raw uint16 passed in.
URI_ID and NFT_INDEX
The contract defines two more bit regions, URI_ID and NFT_INDEX, which are derived or updated later (for metadata lookup and extraction) rather than being directly set in the single-asset mint call.
For fungible ASSETs (supply > 1): all tokens in a given (creator, packId, numFTs) share the same uriId = id & URI_ID, and nftIndex remains zero.
For NFTs (supply == 1): the single-mint path sets packIndex to 0 and leaves NFT_INDEX at 0. NFT_INDEX is always zero for just-minted NFTs; it is only introduced during extraction via _extractERC721From, where it enumerates NFTs within the same collection.
Note that URI_ID is not a stored field but is instead written a mask;
uint256 uriId = id & URI_ID;It zeroes out PACK_INDEX, IS_NFT and NFT_INDEX and keeps the shared upper part i.e. higher-order / left side (creator + packId + numFTTypes).
Conceptual EVM representation
PUSH <URI_ID_mask>
ANDOffsets and masks
All of these fields, creator, isNFT flag, packId, numFTTypes, packIndex, uriId, nftIndex, are combined using a mixture of left-shifts via multipliers, right-shifts during decoding and bit-masking to extract the correct part.
Essentially, the layout follows a hand-crafted bit-packing scheme, which yields three important benefits:
it fits all metadata into a single 256-bit word
it avoids storage writes for these fields, reducing gas
it makes the tokenId itself a compressed descriptor of the ASSET, reducing lookups
It’s clever, but definitely not straightforward; a key reason The Sandbox ultimately replaced this system with its clean ERC1155 implementation.
Final layout
All contract state ultimately goes through the two opcodes SLOAD and SSTORE (expensive in gas terms - refer to the opcodes list for more information on how gas is calculated for storing variables).
In the ASSET contract, all the bit-packing (SHL, OR, AND) happens on the EVM stack. (If you’re interested in learning more about how the stack works, you can check out my earlier article here.) The final generated 256-bit tokenId is written to storage with a single SSTORE. Reading it back uses SLOAD, followed by masks and shifts to decode the fields. Because the EVM always loads and stores full 32-byte words, packing multiple fields into one slot avoids extra SSTOREs and reduces gas.
The final layout for a uint256(tokenId) can be neatly summed up as:
[255–96] CREATOR
[95] IS_NFT
[94–63] NFT_INDEX
[62–23] PACK_ID
[22–11] NUM_FT_TYPES
[10–0] PACK_INDEXI hope you enjoyed the article, thanks for reading!




