ENTRY, NAME, AND SPAWN PREPARATION
Wallet binding and name lookup prepare a request; they do not create a character
The creation page begins by reading the shared browser wallet session. If walletAddress or walletBoundAt is missing, it returns to login. If both strings exist, it displays the shortened address and continues without demanding a fresh message signature or transaction signature at boot. This means the first screen knows which public address the browser says is bound; it has not yet proved that an injected wallet is connected now, that the same person controls it, or that any Player account exists.
As the player types, the frontend trims the name, limits it to 32 Unicode characters and 300 UTF-8 bytes, and accepts ASCII letters, digits, underscore, or any character matched by JavaScript's Script=Han property. After a 420-millisecond pause it derives a UsernameIndex from SHA-256 of the name with only ASCII uppercase letters lowercased, then reads that address at confirmed. A missing record enables the next step. A record already owned by the current wallet is also treated as usable. Alice and alice therefore collide deliberately, while non-ASCII characters are not case-folded.
That read is not a reservation. The ordinary submit path does not force a second RPC read when the matching local status is already available or owned; it reuses that result. If it does perform another lookup and the helper returns null after an RPC error, the current expression result?.available !== false also evaluates true, so only an explicit available: false blocks that preflight. Another transaction can claim the same missing UsernameIndex before this player submits, but the Player Program repeats the PDA and owner checks inside the transaction. There is also a validation mismatch: the browser's Script=Han category includes Han characters outside the contract's narrower U+4E00 through U+9FFF range. Such a name can look valid and available in the page but still be rejected by the program. The final program result, not the green-looking preparation state, decides whether the name was accepted.
Before asking the wallet to sign, the page also calculates a candidate spawn from invite or Genesis Guardian Region information and a locally available world configuration. That position is not included in the character instruction. It is saved to a pending browser record only after the character transaction returns, so neither name lookup nor spawn calculation creates a profile, appearance, name claim, or position write.
walletAddress and walletBoundAt route the browser into creation; they are not a fresh wallet-control proof.
The check derives one UsernameIndex and reads it; no account is locked or reserved by that request.
The helper blocks explicit available=false, but a null error result can currently pass; the on-chain program remains the authority.
Only A through Z are lowercased before SHA-256, so Alice and alice share one predictable name address.
The selected position and camera values are absent from instruction tag 8 and are not PlayerProfile position data.
CURRENT MODEL CHOICE AND CONTRACT BOUNDARY
The client previews two fixed canonical NCM2 models; the contract validates a much wider text envelope
The current page contains exactly two hard-coded choices. The male choice matches the checked-in chr_peasant_guy_blackhair.ncm text after its final newline is removed, and the female choice likewise matches chr_peasant_girl_orangehair.ncm after its final newline is removed. The original fixture files are 354 and 452 bytes including those line endings; the submitted strings are 353 and 451 bytes. The local NCM decoder turns the selected text into cuboids, Three.js draws those boxes, and drag movement rotates the preview. Nothing is minted, uploaded, or derived from the player's face, and the label Generated Chain Code is misleading because the page selects existing canonical text rather than generating a new body.
The boy code contains 353 ASCII characters and decodes to 56 cuboids in 8 colors. The girl contains 451 characters and decodes to 70 cuboids in 10 colors. Both begin with NCM2 and are well below the Appearance model-code ceiling of 2,048 UTF-8 bytes. Copying the code copies public model text to the clipboard; it neither proves that the clipboard is private nor creates an on-chain record.
The current client sends model kind 1 with the boy and model kind 2 with the girl. The Player Program itself checks only that kind is 1 or 2, that the code is valid UTF-8, that it is from 1 through 2,048 bytes, and that the text begins with the case-sensitive letters NCM. It does not call the NCM decoder, verify cuboid geometry, require NCM2 specifically, compare the bytes with either canonical fixture, or bind kind 2 to the girl code. The official page is narrower than what the current contract accepts.
The stored Appearance can contain name, title, one body-model code, and twelve visible-equipment records, but creation sends an empty title and the program initializes every Appearance equipment record as empty. The sentence all character data is on-chain is therefore too broad: the candidate spawn, invite result, browser labels, animation, camera, and other gameplay state are outside this body-code write.
NCM2, 8 colors, and 353 UTF-8 bytes before the fixture file's newline.
NCM2, 10 colors, and 451 UTF-8 bytes before the fixture file's newline.
The page has no body editor, random generator, upload, or arbitrary-code input in its normal creation flow.
The program does not decode geometry or prove that a gender label matches a particular body string.
OWNER APPROVAL, ACCOUNTS, AND STORAGE FUNDING
One owner-signed instruction processes three public records atomically, but does not always rewrite all three
When Create Character is pressed, the page calls ensureNameAvailable, which normally reuses the matching available or owned result already in memory rather than forcing another RPC read. It then selects the current fixed model and calls createPlayerAppearanceOnChain. The chain adapter asks the wallet provider for the owner public key, derives the three PDAs, maps female to model kind 2 and every other submitted gender value to 1, and builds one transaction. The first instruction raises the compute-unit limit to 240,000. The second is Player Program instruction tag 8 with exactly six accounts: owner, PlayerProfile, PlayerAppearance, GlobalConfig, System Program, and UsernameIndex.
The program requires the owner to sign and be writable, checks the writable record accounts, checks the System Program and Core-owned GlobalConfig, and derives the owner-linked profile and appearance addresses itself. It ensures the profile exists, writes the chosen name into it, and creates or validates the canonical UsernameIndex. For the same existing name and owner, UsernameIndex validation returns without rewriting that account. It then creates or validates the Appearance and packs the model and metadata. If any check or allocation fails, the instruction fails as one unit; the page should not describe a partial successful character.
At the finalized Devnet rent schedule observed on 16 July 2026, an empty 773-byte profile required 0.00627096 SOL, a 9,612-byte appearance required 0.0677904 SOL, and a 256-byte name index required 0.00267264 SOL. A first creation missing all three therefore needed about 0.076734 SOL in account balances plus the transaction fee. An existing profile, appearance, or name index can change the amount actually added, and a failed transaction can still have fee consequences. These figures are observations, not a permanent quote.
The page copy says the reserve is not payment to the team, but the actual custody boundary needs more detail. The funded lamports sit in program-owned accounts while those accounts exist. Player Program tag 9 allows only the configured protocol treasury authority to close PlayerAppearance, requires the recipient to be that same treasury, and transfers all Appearance lamports there. The player has no corresponding Appearance close-and-reclaim instruction in the current program. This does not mean the whole first-creation amount is paid immediately to the treasury, but it does mean the player should not be promised unilateral recovery of the Appearance reserve.
Upsert also means replace for Appearance, not append. When an existing Appearance is written again, the program preserves its original creation slot and time but repacks the complete record and initializes all twelve Appearance equipment records to empty. It can therefore erase visible-equipment records stored in that Appearance. NameIndex has different behavior: a same-name upsert only validates the existing index, while a new name creates a new index and does not release the wallet's earlier name index.
One compute-budget instruction precedes one upsert_player_appearance instruction in the same transaction.
First-creation rent observation: 6,270,960 lamports, or 0.00627096 SOL.
First-creation rent observation: 67,790,400 lamports; protocol treasury is the stored close authority.
First-creation rent observation: 2,672,640 lamports; the address represents the ASCII-lowercased name identity.
CONFIRMATION, CACHE, INVITE, AND PLAY HANDOFF
A confirmed character does not prove that every later browser and invite step succeeded
The shared transaction helper sends the signed transaction and stops waiting at confirmed. Only after createPlayerAppearanceOnChain returns a submitted result does the page try to save the username and character information. The name is written only to the global nicechunk.username key. Gender, model code, label, and creation time are each written twice: once under a legacy global key and once under a wallet-scoped key. Those setItem calls have no local try/catch. If storage throws after the chain result, the outer creation catch reports failure and leaves the player on the page even though the accepted transaction cannot be rolled back by that browser error; earlier writes can remain while later ones are missing. Successful localStorage writes make later screens faster, but remain editable device data rather than another signature, chain account, or independent verification.
This distinction matters at the login gateway. If a username is already present and the wallet-scoped character-code key exists, resolveBoundWalletCharacter calls showReady without fetching PlayerAppearance again. When that shortcut is absent, login reads the owner Appearance, checks its owner and program through the chain adapter, and rebuilds the cache from the returned model code. The shortcut does not mean the entire game trusts cache forever: after play/main.js starts, its chainPlayer initial refresh asynchronously fetches PlayerProfile and PlayerAppearance together with progress. The honest boundary is that login can skip the read, while Play later performs its own public-record synchronization.
After the unguarded character-cache writes succeed, creation calls best-effort helpers that attempt to save a pending invite spawn and a local invite receipt, then calls registerInviteResult. Those two helpers catch localStorage failures, so reaching the invite step does not prove that either local record was persisted. When a valid referrer exists, registerInviteResult calls registerInviteOnChain, which builds and submits a separate transaction against an InviteIndex page. A missing inviter page, duplicate record, wallet problem, rejection, RPC error, or program failure can produce a non-submitted or failed result. The character transaction is already complete and is not rolled back; the page attempts to record that result, warns when a valid-referrer append was not submitted, and still redirects to Play.
The pending spawn record is another separate boundary. The creation page calculates it from a Guardian Region and terrain height, but instruction tag 8 does not write that position. The repository's older src/main.js consumes this local record; the current play/main.js entry does not import or call consumePendingInviteSpawn. On the reviewed runtime, attempting to save the draft—or even finding it in storage—is therefore not evidence that Play applied it. The page must not turn Character appearance created on-chain into a claim that cache, invite, spawn, finalization, or every visible Play state also succeeded.
The helper returns after its confirmed polling condition; the page does not wait for the finalized label.
A setItem failure can enter the outer error path after the chain committed; the interface cannot undo that transaction.
The login gateway may skip Appearance, but play/main.js starts a forced chainPlayer refresh that fetches both public records.
Invite failure is recorded or logged but does not reverse the already confirmed character transaction.
The save helper catches localStorage failure and reports no persistence result. src/main.js contains a consumer, but the reviewed play/main.js runtime does not.
- Check the character transaction itself
Use the full signature returned by approved diagnostic tooling on Solana Explorer with cluster=devnet. Confirm that the transaction succeeded, contains Player Program CHZHsBCGn58ih2WrPfKSYhvCEjMPGhArTiYCH7AWWBkB, and invokes tag 8 with the expected owner and derived accounts. A success label without the full signature is weaker evidence.
- Read all three addresses after confirmation
Derive PlayerProfile and PlayerAppearance from the owner, and UsernameIndex from SHA-256 of the ASCII-lowercased name. Verify each account address, Player Program owner, exact data length, magic, stored owner, and relevant name or model fields.
- Test the cache boundary in a clean browser
Open a separate private browser with no character localStorage and bind the same wallet. A real Appearance should be discoverable by RPC and repopulate the session. A ready screen that appears only in the old browser demonstrates cache convenience, not chain reconstruction.
- Check invite and spawn separately
If an invite was supplied, inspect its separate signature and the inviter's InviteIndex rather than assuming character success appended it. Then observe the actual Play starting position; the pending local spawn record is not currently consumed by play/main.js.
READ THE IMPLEMENTATION
The code separates preview, program write, and later browser work
These excerpts show the name-rule mismatch, fixed-model preview, transaction construction, UsernameIndex behavior, Appearance replacement and closure, confirmed polling, cache behavior, separate invite submission, and spawn handoff. The plain-language notes explain what each excerpt proves and what remains outside it.
The implemented creation sequence
local binding → name read → fixed NCM preview → local spawn candidate → owner transaction → confirmed → character-cache attempt → { success: best-effort spawn/receipt saves + optional invite → redirect; failure: error UI, no chain rollback }The arrows show execution order, not equal authority. The first four stages prepare inputs on the device. The owner transaction is the character write. After confirmed, an unguarded character-cache error can stop the later spawn, invite, and redirect path without undoing the transaction. The pending-spawn and receipt helpers catch their own storage errors, so reaching the invite or redirect does not prove those local drafts were saved. A successful redirect also does not prove that the current Play runtime consumed a pending spawn.
- name read
- A point-in-time lookup of one predictable UsernameIndex, not a reservation.
- owner transaction
- One wallet-approved transaction containing compute budget and Player Program tag 8.
- confirmed
- The commitment where the current helper returns; it is not the finalized label.
- character-cache attempt
- The unguarded global-name plus global and wallet-scoped character localStorage writes after the transaction; an exception enters the outer error path and can leave a partial cache.
- best-effort spawn/receipt saves
- Two later localStorage helpers that catch storage errors internally. Calling them does not prove that either record was persisted.
- optional invite transaction
- A second submission that is skipped without a valid referrer and can fail independently.
The three public addresses are derived differently
Profile = PDA(Player, "player-v7", owner); Appearance = PDA(Player, "appearance-v1", owner); Name = PDA(Player, "player-name-v1", SHA256(ASCII-lowercase(name)))The same wallet creates two owner-based addresses, while the name record is based on a public canonical hash. PDA here means predictable public account address, not a recovery phrase or secret seed. The Player Program ID is also part of every PDA derivation.
- owner
- The public key that must sign the character instruction and is stored in the records.
- ASCII-lowercase(name)
- Change A through Z to a through z and leave other UTF-8 characters unchanged.
- SHA256
- A 32-byte public digest used as a fixed-size PDA seed; it is not encryption.
The two official fixtures are only a small subset of the accepted model envelope
{(1, canonical boy NCM2), (2, canonical girl NCM2)} ⊂ {(kind, code) | kind ∈ {1, 2}; code is UTF-8; 1–2,048 bytes; code starts with case-sensitive NCM}A strict subset means every official page choice fits the contract rules, but many other strings can fit those rules too. The client offers exactly two decoded fixtures. The program validates only the kind number, UTF-8 and byte limits, and the NCM prefix; it does not prove NCM2 geometry or pair one fixture with one kind.
- left-hand set
- The two exact trimmed strings that the official creation page lets a player preview and submit.
- ⊂
- Strict subset: everything on the left is allowed on the right, but the right side also allows other values.
- accepted model envelope
- The outer text-and-size checks enforced by the Player Program; it is not a full NCM geometry validation.
Observed first-creation storage funding
0.00627096 SOL + 0.06779040 SOL + 0.00267264 SOL = 0.07673400 SOL, plus transaction feeThis sum assumes all three accounts are absent and uses finalized Devnet rent queries captured on 16 July 2026. Existing funded accounts can reduce the lamports added. Network rent rules and fees can change, and the current Appearance close path sends reclaimed Appearance lamports to the protocol treasury rather than the player.
- 0.00627096 SOL
- Observed rent-exempt minimum for 773 PlayerProfile bytes.
- 0.06779040 SOL
- Observed rent-exempt minimum for 9,612 PlayerAppearance bytes.
- 0.00267264 SOL
- Observed rent-exempt minimum for 256 UsernameIndex bytes.
- transaction fee
- A separate fee for processing the transaction; it is not included in the account-balance sum.
One result cannot stand in for every later result
characterConfirmed ≠ appearanceReadBack ≠ cacheWritten ≠ cacheTrusted ≠ inviteRegistered ≠ spawnApplied ≠ finalizedThe not-equal signs do not say that later steps always fail. They say each claim has its own evidence source and failure boundary. A careful status message names the exact stage instead of compressing all seven into created.
- characterConfirmed
- The tag 8 transaction satisfied the client's confirmed wait.
- appearanceReadBack
- A later RPC read found the derived Appearance account and validated its program owner and stored owner before decoding it.
- cacheWritten
- The creation page's unguarded username and character cache sequence completed without throwing; this is still editable device state.
- cacheTrusted
- The login UI accepted wallet-scoped browser data; that can happen without a new Appearance read.
- inviteRegistered
- A separate append transaction succeeded or an existing matching InviteIndex record was independently verified.
- spawnApplied
- The actual Play runtime used the intended position, not merely that creation attempted to save a local draft.
- finalized
- The transaction later reached Solana's stronger finalized commitment; the creation helper does not wait for this label.
The availability check reads one derived name account
JavaScriptsrc/chain/nicechunkChain.jsexport async function checkPlayerNameAvailability(playerName, ownerAddress = null) {
const { normalized } = encodePlayerName(playerName);
if (!normalized) return { available: false, reason: "empty-player-name" };
const [usernameIndex] = await deriveUsernameIndexPdaForName(normalized);
const account = await getNicechunkConnection().getAccountInfo(usernameIndex, "confirmed");
if (!account?.data?.length) {
return {
available: true,
usernameIndex: usernameIndex.toBase58(),
playerName: normalized,
};
}
The client normalizes the proposed text, derives one predictable UsernameIndex, and performs a confirmed read. When no data is returned it reports available to the interface. There is no lock, reservation transaction, signature, or later-state guarantee in this excerpt.
- 1-3
Normalize the submitted name and reject an empty result before any RPC request.
- 4-5
Derive the canonical name PDA and read exactly that account at confirmed.
- 6-12
Treat a missing account as available for this response and return the candidate address and display name.
The browser accepts the Unicode Script=Han category
JavaScriptsrc/chain/nicechunkChain.jsfunction encodePlayerName(playerName) {
const normalized = String(playerName ?? "").trim();
if (Array.from(normalized).length > playerNameMaxChars) {
throw new Error(`Player name is too long: max ${playerNameMaxChars} characters.`);
}
if (!/^[\p{Script=Han}A-Za-z0-9_]*$/u.test(normalized)) {
throw new Error("Player name contains unsupported characters.");
}
const bytes = Buffer.from(normalized, "utf8");
if (bytes.length > playerNameMaxBytes) {
throw new Error(`Player name is too large: max ${playerNameMaxBytes} UTF-8 bytes.`);
}
return { normalized, bytes };
}
The browser counts Unicode characters, applies its byte ceiling, and accepts ASCII letters, digits, underscore, or any character in JavaScript's broad Script=Han property. This is a useful client check, but it is not the contract's exact rule and cannot promise that submission will pass.
- 1-5
Trim the text and enforce the browser's character-count ceiling.
- 6-8
Apply the broad Script=Han-or-ASCII pattern used by the page.
- 9-14
Encode UTF-8, enforce the byte ceiling, and return the normalized text and bytes.
The contract accepts only its explicit Han code-point interval
Rustprograms/nicechunk_player/src/state.rs pub fn validate_name(name: &[u8]) -> Result<&str, NicechunkPlayerError> {
if name.len() > PLAYER_NAME_MAX_BYTES {
return Err(NicechunkPlayerError::InvalidPlayerName);
}
let value =
core::str::from_utf8(name).map_err(|_| NicechunkPlayerError::InvalidPlayerName)?;
let mut char_count = 0usize;
for ch in value.chars() {
char_count += 1;
if char_count > PLAYER_NAME_MAX_CHARS {
return Err(NicechunkPlayerError::InvalidPlayerName);
}
let valid =
ch == '_' || ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch);
if !valid {
return Err(NicechunkPlayerError::InvalidPlayerName);
}
}
Ok(value)
}
The Player Program independently checks UTF-8, the same configured size ceilings, and an explicit character rule. Its Han interval is only U+4E00 through U+9FFF, which is narrower than JavaScript Script=Han. A character outside that interval can pass the page's green preparation state and still fail the transaction.
- 1-6
Reject an over-budget or invalid UTF-8 byte sequence before inspecting characters.
- 7-12
Count Unicode scalar values and enforce the contract's character ceiling.
- 13-20
Allow only underscore, ASCII alphanumeric, or the explicit U+4E00–U+9FFF interval.
Submission normally reuses cached availability and treats only explicit false as blocked
JavaScriptplayer_creat/player_creat.jsasync function ensureNameAvailable(name) {
const normalized = String(name || "").trim();
if (!isValidName(normalized)) return false;
if (nameAvailability.name === normalized && nameAvailability.status === "available") return true;
if (nameAvailability.name === normalized && nameAvailability.status === "owned") return true;
const result = await checkCurrentNameAvailability(normalized);
return result?.available !== false;
}
A matching available or owned status returns immediately without a fresh RPC read. When another check is needed, the final comparison rejects only the literal value false. Because null?.available is undefined and undefined is not false, an RPC-error null can pass this preflight. The Player Program's PDA and owner checks remain the final authority and can still reject the transaction.
- 1-3
Trim the name and stop on a local format failure.
- 4-5
Reuse an in-memory success for the exact same text rather than reading the name account again.
- 6-7
When a lookup runs, block explicit unavailable but currently allow null or other results that are not literal false.
Selecting a model decodes and previews already supplied text
JavaScriptplayer_creat/player_creat.jsfunction selectModel(index) {
activeIndex = Math.max(0, Math.min(characterModels.length - 1, index));
const model = characterModels[activeIndex];
currentCharacter = decodeNcm(model.code);
selectedModelName.textContent = t(model.labelKey);
chainCode.value = model.code;
codeBytes.textContent = String(new TextEncoder().encode(model.code).length);
boxCount.textContent = String(currentCharacter.boxes.length);
codePrefix.textContent = model.code.split(":", 1)[0] || "NCM";
genderCards.forEach((card) => card.classList.toggle("is-active", card.dataset.gender === model.gender));
renderCharacter(currentCharacter);
updateCreateState();
}
characterModels is the two-entry array defined above this function. Selection clamps to that array, decodes its existing code, displays byte and cuboid counts, and renders a local preview. The function does not generate a new NCM string, ask the wallet to sign, or write an account.
- 1-3
Choose one entry from the fixed array; an out-of-range index is clamped back into that array.
- 4-9
Decode the selected string and expose its label, exact text, UTF-8 byte count, cuboid count, and prefix.
- 10-12
Update the local selected style, draw the decoded cuboids, and reevaluate whether the page can continue.
The page waits for the character call, then performs unguarded browser cache writes
JavaScriptplayer_creat/player_creat.js const model = characterModels[activeIndex];
const spawnState = await createInitialSpawnState();
const { createPlayerAppearanceOnChain } = await import("../src/chain/nicechunkChain.js");
const result = await createPlayerAppearanceOnChain({
playerName: name,
gender: model.gender,
title: "",
ncmCode: model.code,
});
if (result?.submitted === false) throw new Error(result.reason || "player-appearance-not-saved");
persistUsername(name);
localStorage.setItem(characterStorageKeys.gender, model.gender);
localStorage.setItem(characterStorageKeys.code, model.code);
localStorage.setItem(characterStorageKeys.label, t(model.labelKey));
localStorage.setItem(characterStorageKeys.createdAt, String(Date.now()));
localStorage.setItem(walletCharacterStorageKey(characterStorageKeys.gender), model.gender);
localStorage.setItem(walletCharacterStorageKey(characterStorageKeys.code), model.code);
localStorage.setItem(walletCharacterStorageKey(characterStorageKeys.label), t(model.labelKey));
localStorage.setItem(walletCharacterStorageKey(characterStorageKeys.createdAt), String(Date.now()));
The spawn candidate is calculated first but is not passed to createPlayerAppearanceOnChain. The awaited character call receives only name, gender label, empty title, and fixed model code. Browser name and model caches are written afterward, but these setItem calls have no inner error guard. A storage exception reaches submitCharacter's outer catch after the chain may already be confirmed, so the UI can say failure without reversing the public records.
- 1-2
Choose the fixed model and calculate a candidate spawn as separate local preparation.
- 3-10
Load the chain adapter, await the appearance upsert, and turn an explicit not-submitted result into failure.
- 11-19
Only afterward save the name plus legacy and wallet-scoped copies of the model selection in localStorage.
The chain adapter builds one 240,000-unit transaction
JavaScriptsrc/chain/nicechunkChain.js const [playerProfile] = derivePlayerProfilePda(provider.publicKey);
const [appearance] = derivePlayerAppearancePda(provider.publicKey);
const [usernameIndex] = await deriveUsernameIndexPdaForName(normalized);
const modelKind = String(gender).toLowerCase() === "female" ? 2 : 1;
const tx = new Transaction();
tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 240_000 }));
tx.add(createUpsertPlayerAppearanceInstruction({
authority: provider.publicKey,
playerProfile,
appearance,
usernameIndex,
modelKind,
playerNameBytes: nameBytes,
titleBytes,
codeBytes,
}));
const conn = getNicechunkConnection();
const signature = await signAndSendWalletTransaction(provider, tx, conn);
The public owner determines the profile and appearance addresses, while the canonical name determines the index. The transaction contains a compute-budget instruction and one upsert instruction, then goes through the separate wallet signing and confirmed-submission helper. The locally calculated spawn is absent.
- 1-4
Derive three different PDAs and map the client gender label to the one-byte model kind.
- 5-16
Build one transaction with a 240,000 compute-unit limit and the exact character payload and accounts.
- 17-18
Submit through the wallet path and await the helper's result before returning to the creation page.
Instruction tag 8 names all six accounts
JavaScriptsrc/chain/nicechunkChain.js const header = Buffer.alloc(8);
header.writeUInt8(8, 0);
header.writeUInt8(Number(modelKind) === 2 ? 2 : 1, 1);
header.writeUInt16LE(name.length, 2);
header.writeUInt16LE(title.length, 4);
header.writeUInt16LE(code.length, 6);
return new TransactionInstruction({
programId: playerProgramId,
keys: [
{ pubkey: authority, isSigner: true, isWritable: true },
{ pubkey: playerProfile, isSigner: false, isWritable: true },
{ pubkey: appearance, isSigner: false, isWritable: true },
{ pubkey: deriveGlobalConfigPda(), isSigner: false, isWritable: false },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: usernameIndex, isSigner: false, isWritable: true },
],
data: Buffer.concat([header, name, title, code]),
});
The first header byte chooses Player Program tag 8. The remaining header declares model kind and byte lengths, followed by the exact name, title, and code bytes. The six account metas show which public records can change and which owner must sign; no invite index or spawn position is present.
- 1-6
Encode the instruction tag, normalized model kind, and three byte lengths into an eight-byte header.
- 7-16
Target the Player Program and declare one signer, three writable PDAs, GlobalConfig, and the System Program.
- 17-18
Concatenate the header and exact text bytes into the instruction payload.
An existing name index is validated; a missing one is created
Rustprograms/nicechunk_player/src/lib.rs let name_hash = canonical_player_name_hash(player_name);
let (expected_username_index, bump) = username_index_pda(program_id, &name_hash);
require_key_eq(
username_index.key,
&expected_username_index,
NicechunkPlayerError::InvalidUsernameIndexPda,
)?;
if username_index.owner == program_id {
let data = username_index.try_borrow_data()?;
return UsernameIndex::validate_owner_or_available(
&data,
owner,
player_profile,
global_config,
&name_hash,
);
}
if username_index.owner != &system_program::ID || username_index.data_len() != 0 {
return Err(NicechunkPlayerError::UsernameAlreadyTaken.into());
}
create_or_allocate_username_index_pda(
payer,
username_index,
system_program_account,
program_id,
&name_hash,
bump,
)?;
let mut data = username_index.try_borrow_mut_data()?;
UsernameIndex::pack(
&mut data,
&UsernameIndexInitArgs {
bump,
owner,
player_profile,
global_config,
name_hash: &name_hash,
display_name: player_name,
created_slot: clock.slot,
},
)
}
The program derives the index for the submitted canonical name. If that account is already Player-owned, it returns from validation without repacking it. If the address is still an empty System account, it allocates and packs a new index. Tag 8 supplies only this one submitted-name index, so changing to a new name creates the new index but provides no earlier index for this path to close or release.
- 1-7
Hash the submitted canonical name, derive its exact PDA, and reject a different address.
- 8-17
For an existing Player-owned index, validate its owner and linked records, then return without rewriting it.
- 18-28
Reject a nonempty foreign account; otherwise allocate the missing index at the derived address.
- 29-42
Pack the owner, linked profile, configuration, canonical hash, display name, and creation slot into a new index.
Only the configured treasury can close Appearance and receive its lamports
Rustprograms/nicechunk_player/src/lib.rsfn close_player_appearance(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
if accounts.len() != 3 {
return Err(NicechunkPlayerError::InvalidAccountCount.into());
}
let account_info_iter = &mut accounts.iter();
let treasury_authority = next_account_info(account_info_iter)?;
let appearance = next_account_info(account_info_iter)?;
let recipient = next_account_info(account_info_iter)?;
if !treasury_authority.is_signer || !treasury_authority.is_writable || !recipient.is_writable {
return Err(NicechunkPlayerError::InvalidTreasuryAuthority.into());
}
require_key_eq(
treasury_authority.key,
&NICECHUNK_TREASURY_AUTHORITY,
NicechunkPlayerError::InvalidTreasuryAuthority,
)?;
require_key_eq(
recipient.key,
treasury_authority.key,
NicechunkPlayerError::InvalidTreasuryAuthority,
)?;
require_key_eq(
appearance.owner,
program_id,
NicechunkPlayerError::InvalidAppearanceOwner,
)?;
{
let data = appearance.try_borrow_data()?;
PlayerAppearance::validate_treasury_authority(&data, treasury_authority.key)?;
let owner = PlayerAppearance::owner(&data)?;
let (expected_appearance, _) =
Pubkey::find_program_address(&[PLAYER_APPEARANCE_SEED, owner.as_ref()], program_id);
require_key_eq(
appearance.key,
&expected_appearance,
NicechunkPlayerError::InvalidAppearancePda,
)?;
}
{
let mut data = appearance.try_borrow_mut_data()?;
data.fill(0);
}
let reclaimed_lamports = appearance.lamports();
**recipient.try_borrow_mut_lamports()? = recipient
.lamports()
.checked_add(reclaimed_lamports)
.ok_or(NicechunkPlayerError::InvalidAppearanceData)?;
**appearance.try_borrow_mut_lamports()? = 0;
Ok(())
}
Tag 9 is not a player refund path. It requires the configured treasury to sign, requires the receiving account to be that same treasury, checks the program-owned owner-derived Appearance, clears its data, and transfers every lamport to the treasury. The player funds Appearance creation but has no corresponding close-and-reclaim instruction here.
- 1-8
Require exactly the treasury signer, the Appearance account, and the recipient.
- 10-27
Require writable accounts, the configured treasury key, the same key as recipient, and Player Program ownership.
- 29-40
Verify the treasury stored in Appearance and the Appearance PDA derived from its player owner.
- 41-52
Zero the account data, transfer all lamports to the treasury recipient, and leave Appearance with zero lamports.
The contract checks an envelope, not NCM geometry
Rustprograms/nicechunk_player/src/state.rs pub fn validate_model_kind(model_kind: u8) -> Result<(), NicechunkPlayerError> {
if model_kind != CHARACTER_MODEL_KIND_MALE && model_kind != CHARACTER_MODEL_KIND_FEMALE {
return Err(NicechunkPlayerError::InvalidCharacterModelKind);
}
Ok(())
}
pub fn validate_title(title: &[u8]) -> Result<&str, NicechunkPlayerError> {
if title.len() > APPEARANCE_TITLE_MAX_BYTES {
return Err(NicechunkPlayerError::InvalidAppearanceTitle);
}
core::str::from_utf8(title).map_err(|_| NicechunkPlayerError::InvalidAppearanceTitle)
}
pub fn validate_model_code(code: &[u8]) -> Result<&str, NicechunkPlayerError> {
if code.is_empty() || code.len() > APPEARANCE_MODEL_CODE_MAX_BYTES {
return Err(NicechunkPlayerError::InvalidCharacterCode);
}
let value =
core::str::from_utf8(code).map_err(|_| NicechunkPlayerError::InvalidCharacterCode)?;
if !value.starts_with("NCM") {
return Err(NicechunkPlayerError::InvalidCharacterCode);
}
Ok(value)
}
The program admits only model kinds 1 and 2, validates UTF-8 title and code sizes, and requires an NCM prefix. There is no NCM decoder, cuboid count, palette check, NCM2 version requirement, or comparison between model kind and fixture bytes in these validators.
- 1-6
Accept exactly the two numeric model-kind values; this validates a label byte, not body geometry.
- 8-13
Require the optional title to fit its byte budget and be valid UTF-8.
- 15-25
Require a nonempty in-budget UTF-8 model string with case-sensitive NCM prefix, then return the text without decoding it.
Repacking Appearance initializes all twelve visible-equipment records as empty
Rustprograms/nicechunk_player/src/state.rs writer.bytes(args.model_code)?;
writer.bytes(
&[0_u8; APPEARANCE_MODEL_CODE_MAX_BYTES]
[..APPEARANCE_MODEL_CODE_MAX_BYTES - args.model_code.len()],
)?;
for slot in 0..APPEARANCE_EQUIPMENT_SLOT_COUNT {
writer.u8(0)?;
writer.u8(slot as u8)?;
writer.bytes(&[0_u8; APPEARANCE_EQUIPMENT_SLOT_LEN - 2])?;
}
After writing and padding the model code, pack writes state zero and zero-filled bytes for every Appearance equipment slot. The same pack routine runs for a new record and an upsert of an existing record, so a later character upsert can clear visible-equipment records already stored there.
- 1-5
Write the exact model-code bytes and pad the fixed 2,048-byte code region with zeros.
- 6-10
For all twelve slots, write empty state, the slot number, and zeros for the rest of the fixed record.
The wallet helper waits for confirmed, not finalized
JavaScriptsrc/chain/nicechunkChain.js if (typeof provider.signTransaction === "function") {
const signed = await provider.signTransaction(transaction);
const signature = await sendRawTransactionWithLogs(conn, signed.serialize(), "wallet");
await confirmTransactionByHttpPolling(conn, {
signature,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight,
}, "confirmed");
return signature;
}
if (typeof provider.signAndSendTransaction !== "function") {
throw new Error("Wallet does not support transaction signing.");
}
const result = await provider.signAndSendTransaction(transaction);
const signature = typeof result === "string" ? result : result?.signature;
if (!signature) throw new Error("Wallet did not return a transaction signature.");
await confirmTransactionByHttpPolling(conn, {
signature,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight,
}, "confirmed");
return signature;
Both supported wallet paths submit a signed transaction and then call the same HTTP polling helper with the literal commitment confirmed. A returned signature here means that polling condition was met. It does not say that the page waited for Solana's later finalized label.
- 1-10
For wallets that sign locally, serialize, submit, poll to confirmed, and return the signature.
- 12-17
For sign-and-send wallets, require support and require an actual returned signature.
- 18-23
Poll that second path to the same confirmed commitment and then return.
Pending spawn and invite receipt saves swallow storage errors
JavaScriptsrc/player/inviteSpawn.jsexport function storePendingInviteSpawn(walletAddress, payload) {
const wallet = normalizeParam(walletAddress);
if (!wallet || !payload) return;
try {
localStorage.setItem(inviteSpawnStorageKey(wallet), JSON.stringify({
...payload,
storedAt: Date.now(),
}));
} catch {
// Spawn still works from the chain PDA if local storage is unavailable.
}
}
export function consumePendingInviteSpawn(walletAddress, { surfaceHeight } = {}) {
const wallet = normalizeParam(walletAddress);
if (!wallet) return null;
const key = inviteSpawnStorageKey(wallet);
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
localStorage.removeItem(key);
const parsed = JSON.parse(raw);
const state = parsed?.position
? {
position: parsed.position,
yaw: Number.isFinite(Number(parsed.yaw)) ? Number(parsed.yaw) : Math.PI * 0.25,
cameraPitch: Number.isFinite(Number(parsed.cameraPitch)) ? Number(parsed.cameraPitch) : -0.42,
guardianRegion: normalizeGuardianRegion(parsed.guardianRegion),
}
: guardianSpawnStateForRegion(parsed?.guardianRegion, { surfaceHeight });
return state;
} catch {
localStorage.removeItem(key);
return null;
}
}
export function storeInviteReceipt(walletAddress, receipt) {
const wallet = normalizeParam(walletAddress);
if (!wallet || !receipt) return;
try {
localStorage.setItem(inviteReceiptStorageKey(wallet), JSON.stringify({
...receipt,
storedAt: Date.now(),
}));
} catch {
// Non-critical analytics cache.
}
}
Both save helpers return no success value and catch localStorage exceptions internally. Their caller therefore continues whether the write succeeded or failed and cannot infer persistence from the absence of an exception. The middle function shows how a runtime can consume and delete a saved spawn, but merely defining that consumer does not prove that the current Play entry calls it.
- 1-12
Attempt the wallet-scoped pending-spawn write; swallow any storage exception and report no result.
- 14-36
A caller that uses the consumer reads once, removes the draft, normalizes it, and returns null on failure.
- 38-49
Attempt the wallet-scoped invite-receipt write; again swallow storage failure and report no result.
Invite registration constructs and submits another transaction
JavaScriptsrc/chain/nicechunkChain.js const pageIndex = Number(targetPage.pageIndex);
const [inviteIndex] = deriveInviteIndexPda(inviter, pageIndex);
const previousInviteIndex = pageIndex > 0 ? deriveInviteIndexPda(inviter, pageIndex - 1)[0] : null;
const tx = new Transaction().add(createAppendInviteRegistrationInstruction({
invited: provider.publicKey,
inviter,
inviteIndex,
pageIndex,
previousInviteIndex,
}));
const conn = getNicechunkConnection();
const signature = await signAndSendWalletTransaction(provider, tx, conn);
return {
submitted: true,
signature,
invited: provider.publicKey.toBase58(),
inviter: inviter.toBase58(),
inviteIndex: inviteIndex.toBase58(),
pageIndex,
capacity: inviteIndexCapacity,
programId: playerProgramId.toBase58(),
};
}
This is the end of registerInviteOnChain, not the character upsert. It derives an InviteIndex page, creates a new Transaction containing the append instruction, asks the wallet path to submit it, and returns its own signature. Character confirmation cannot substitute for this separate signature or InviteIndex read.
- 1-3
Choose and derive the inviter's target InviteIndex page and optional previous page.
- 4-10
Build a new transaction containing the invite append accounts and page information.
- 11-23
Submit separately and return a distinct signature and InviteIndex result.
Only the alternate startup path shown here consumes the pending spawn
JavaScriptsrc/main.jsasync function resolveConfiguredInitialPlayerState() {
const inviteSpawnState = consumePendingInviteSpawn(currentSession.walletAddress, { surfaceHeight });
const normalizedInviteState = normalizedStartupPlayerState(inviteSpawnState);
if (normalizedInviteState) return normalizedInviteState;
const chainPlayerState = await loadPlayerPdaPositionState();
if (chainPlayerState) return chainPlayerState;
This older or alternate src/main.js path explicitly consumes the wallet-scoped spawn draft before falling back to the chain Player position. The audited current entry is play/main.js, whose captured source contains no import or call of consumePendingInviteSpawn. The excerpt proves that a consumer exists elsewhere; the source audit, not this excerpt alone, establishes that it is absent from the reviewed Play entry.
- 1-4
Consume and normalize a pending invite spawn, then prefer it when valid.
- 6-7
Only after that alternate local handoff fails does this path try Player position state.
The login gateway can accept a wallet-scoped cache before reading Appearance
JavaScriptlogin/login.jsasync function resolveBoundWalletCharacter(sequence) {
if (username && hasLocalCharacterCreated(walletAddress)) {
await maybeRegisterInviteForExistingCharacter({ displayName: username, playerName: username });
if (sequence !== loginResolutionSequence) return;
showReady();
return;
}
statusLine.textContent = t("login.status.networkChecking");
try {
const { fetchPlayerAppearanceForOwner } = await import("../src/chain/nicechunkChain.js");
const appearance = await fetchPlayerAppearanceForOwner(walletAddress);
if (sequence !== loginResolutionSequence) return;
if (appearance?.modelCode) {
restoreCharacterSessionFromAppearance(appearance);
await maybeRegisterInviteForExistingCharacter(appearance);
showReady();
return;
}
The first branch requires only a local username and wallet-scoped character-code key, optionally handles an invite, and shows ready without an Appearance RPC read. The second branch performs the chain read only when that cache shortcut did not return. This is specifically a login-gateway convenience boundary: play/main.js later starts an asynchronous Profile and Appearance refresh, so the excerpt does not show Play permanently replacing chain state with cache.
- 1-7
If both local signals exist, run optional invite handling and mark the login interface ready.
- 9-13
Otherwise begin the network path and fetch the wallet's public Appearance account.
- 15-20
When chain data contains a model code, restore browser state from it, handle invite separately, and then show ready.
IMPLEMENTATION EVIDENCE
Where these claims come from
Each claim is intentionally scoped to a concrete implementation path. These references are for verification, not decoration.
player_creat/player_creat.js
Defines the two fixed NCM2 model strings, local wallet-session entry check, debounced name lookup, frontend name rules, local preview, candidate spawn calculation, character call, one global username write, global and wallet-scoped model-field writes, best-effort pending-spawn and receipt calls, separate invite attempt, and redirect.
player_creat/index.html
Contains the current user-facing Generated Chain Code, all character data, storage reserve, and not payment to team statements whose stronger implications are qualified in this guide.
src/chain/nicechunkChain.js
Derives the three PDAs, canonicalizes ASCII case for the name hash, reads name availability, enforces client byte envelopes, constructs tag 8 and its six accounts, requests wallet submission, waits at confirmed, and decodes owner-checked Appearance data.
programs/nicechunk_player/src/lib.rs
Routes tag 8, validates signer and accounts, computes the ASCII-lowercased name hash, ensures Profile and UsernameIndex without releasing an earlier index, creates or validates Appearance, preserves creation metadata, repacks it, and gives tag 9 close and reclaim authority only to the configured treasury.
programs/nicechunk_player/src/state.rs
Defines account seeds and exact lengths, the narrower contract name character range, Appearance fields and twelve equipment records, NCM prefix-only model validation, and full pack behavior.
programs/nicechunk_player/src/cluster_config.rs
Defines the current protocol treasury authority stored in Appearance and required by the close path.
public/media/vox/chr_peasant_guy_blackhair.ncm
Provides the canonical boy NCM2 fixture: 354 file bytes including the final newline, whose 353-byte trimmed text matches the creation-page string and decodes to 56 cuboids in 8 colors.
public/media/vox/chr_peasant_girl_orangehair.ncm
Provides the canonical girl NCM2 fixture: 452 file bytes including the final newline, whose 451-byte trimmed text matches the creation-page string and decodes to 70 cuboids in 10 colors.
src/vox/ncm.js
Implements the browser-side NCM decoder used by the preview; the Player Program does not execute this JavaScript decoder.
login/login.js
Shows the login shortcut that combines one global username with a wallet-scoped character-code key to skip a new Appearance read, plus the fallback chain read that restores browser character data.
src/player/inviteSpawn.js
Defines invite parameter parsing, Guardian Region spawn calculation, best-effort wallet-scoped pending-spawn and receipt storage that catches localStorage failures, and pending-spawn consumption for runtimes that call it.
play/main.js
Is the current Play entry reviewed for this guide, starts a forced asynchronous PlayerProfile and PlayerAppearance refresh, and does not import or call consumePendingInviteSpawn.
play/play-chain-player.js
Implements the Play-side refresh that fetches PlayerProfile, PlayerAppearance, and progress concurrently after the login gateway has finished.
src/main.js
Contains an older or alternate startup path that does consume the pending invite spawn, demonstrating why its presence elsewhere must not be attributed to the current Play entry.
target/deploy/nicechunk_player.so
The retained local Player artifact is 172,880 bytes and matched the finalized Devnet ProgramData allocation hash during the dated audit; modified source files postdate the artifact and are not thereby proven deployed.
docs/audits/character-creation-devnet-2026-07-16.json
Records finalized Devnet genesis, RPC version, Player Program and ProgramData observations, local-artifact comparison, account-size rent queries, source hashes, NCM measurements, and explicit deployment limits.
docs/visual-provenance/character-creation.json
Records the canonical villager, real Chunk.js world, baked-material references, selected imagegen outputs, hashes, dimensions, prompt records, review decisions, and metaphor limitations for all five illustrations.