PLAYER, WALLET, AND CONTROL 04 · CHARACTER CREATION

Creating a character is one chain write surrounded by several local steps

The current creation page starts from a browser wallet binding, checks a name without reserving it, previews one of two fixed NCM2 villagers, calculates a candidate spawn locally, and then asks the owner wallet to approve one Player Program transaction. On first creation that transaction can create a PlayerProfile, PlayerAppearance, and UsernameIndex together; on later upserts it rewrites or validates them according to each record's rules. Only after the transaction returns at confirmed does the page try to write character caches, attempt a separate invite transaction when needed, and redirect to Play. Those outcomes are related, but they are not one proof.

28 min read
In a teaching scene generated from the canonical NiceChunk villagers and a real Chunk.js hillside reference, the boy works a wooden press above three distinct counter forms—two shallow trays and one open case containing a small block-pattern model—while the girl watches; separate cyan and orange frames remain away from the counter.
The press and three counter forms represent one approved action processing three separate public records: first creation can create all three, while a later upsert may rewrite a record or only validate it. The open model case represents the chosen NCM body, while the distant cyan frame and separate orange tray represent spawn and invite outcomes outside that character transaction. Every object is a teaching metaphor, not a wallet control, PDA, transaction, model decoder, spawn marker, or in-game status indicator.
PLAYER QUESTION What really happens when I choose a name and model, approve Create Character, and enter the world?
Learning path
Player, wallet, and control · Step 4 of 15
Audience
Players with no programming or blockchain background
Implemented boundary
Local preparation · owner-signed Player transaction · confirmed result · local cache · optional second invite transaction
Reviewed
16 July 2026 · current dirty working tree, canonical NCM fixtures, contract source, and finalized Devnet rent and deployment observations
Wallet binding
The public address and binding time saved by the login page in this browser. The creation page requires those local fields before it opens; their presence is not a fresh cryptographic proof that the wallet provider is connected or controlled now.
Name availability read
A confirmed RPC lookup of the predictable UsernameIndex address for a proposed name. A missing account means the name appears available at that read; it does not reserve the address or guarantee a later transaction will win.
Canonical name hash
SHA-256 over the UTF-8 name after ASCII A through Z are changed to lowercase. Other characters are left unchanged. Alice and alice therefore address the same UsernameIndex.
NCM2 code
Compact text that the client decoder can turn into colored cuboids. The two current creation choices are checked-in canonical strings; the page does not generate a new body code from player choices.
Model kind
One byte stored as 1 for male or 2 for female by the current client. The program validates only those two numbers; it does not prove that kind 2 contains the canonical girl geometry.
Transaction
A signed packet of exact instructions and accounts sent to Solana. Character creation builds one transaction containing a compute-budget instruction and one Player Program instruction.
Upsert
One operation that can create a missing record or handle an existing one. It does not mean every record is appended or rewritten in the same way: this character flow rewrites Profile and Appearance data, validates an existing same-name index, and creates another index when a different unused name is chosen.
RPC read
A request to a Solana network server for public account data. It can report what that server observed at a stated commitment, but a read is not a wallet signature, account lock, name reservation, or transaction.
PDA
A predictable program-owned account address derived from public inputs. A PDA has no wallet private key. Character creation derives different addresses for the profile, appearance, and name index.
PlayerProfile
The 773-byte owner-linked public record. During first creation it receives defaults and the chosen display name; later upserts keep the account and rewrite its name and update slot.
PlayerAppearance
The 9,612-byte public record containing owner and profile links, treasury close authority, model kind, display name, title, NCM code, timestamps, and twelve visible-equipment records.
UsernameIndex
The 256-byte one-name-one-owner lookup record addressed by the canonical name hash. It makes a direct uniqueness check possible without scanning every player account.
Lamport
The smallest native SOL unit. One SOL equals 1,000,000,000 lamports, so programs keep integer lamport balances even when the page displays a decimal SOL amount.
Rent-exempt minimum
Lamports required for a data account under the observed Solana rent schedule. The owner funds missing accounts and shortfalls when the program allocates or reallocates them; the current existing-Appearance path also tops up its rent balance. A valid existing Profile or UsernameIndex returns without a general rent top-up. Transaction fees are separate.
Confirmed
The commitment at which the current transaction helper stops waiting. It is stronger than merely sending a signature, but it is not the same label as finalized and says nothing by itself about later local cache or invite work.
Finalized
A stronger Solana commitment label for a transaction rooted by the network. The current creation helper returns once its confirmed condition is met, so the page must not silently relabel that result as finalized.
Local character cache
Editable localStorage data written after the character transaction returns. The name uses one global nicechunk.username key; gender, model code, label, and creation time are each attempted in both legacy-global and wallet-scoped keys. These writes are unguarded, so an exception can leave a partial cache.
Invite registration
A separate Player Program transaction that may append the new wallet to an inviter's page. It happens after the character transaction and can fail without undoing the character.
Pending invite spawn
A wallet-scoped browser record containing a locally calculated position, camera angles, Guardian Region, and invite parameters. The page calls a best-effort helper that catches storage failure, so persistence is not guaranteed. It is not written by the character instruction, and the current play/main.js runtime does not consume it.

Key points

Checking a name is preparation, not ownership

The page reads a UsernameIndex before enabling the next step, then ordinarily reuses that cached available or owned result at submission. Only the program's accepted transaction can establish or validate the owner relationship.

The current choice is exactly two fixed models

The boy is a 353-character NCM2 string with 56 cuboids; the girl is a 451-character NCM2 string with 70 cuboids. Previewing or copying either string makes no chain write.

One owner approval can touch three records

Instruction tag 8 receives six accounts and atomically processes the owner's PlayerProfile, PlayerAppearance, and canonical UsernameIndex. First creation can create all three; a same-name upsert validates the existing name index without rewriting it.

Confirmed character, cached login, invite, and spawn are different outcomes

The page attempts cache writes only after confirmed, then separately attempts invite registration and redirects. Cache failure can make the page report failure after the chain already committed; the Play runtime later fetches Profile and Appearance, invite failure does not roll back creation, and the current Play entry never applies the pending spawn record.

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.

Page entry requires Two local wallet-binding fields

walletAddress and walletBoundAt route the browser into creation; they are not a fresh wallet-control proof.

Lookup timing 420 ms debounce, then confirmed RPC read

The check derives one UsernameIndex and reads it; no account is locked or reserved by that request.

Submit preflight Cached success is normally reused

The helper blocks explicit available=false, but a null error result can currently pass; the on-chain program remains the authority.

Name identity ASCII-case-insensitive

Only A through Z are lowercased before SHA-256, so Alice and alice share one predictable name address.

Candidate spawn Local preparation only

The selected position and camera values are absent from instruction tag 8 and are not PlayerProfile position data.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the boy holds one plain rectangular wooden inspection card with a centered square inset beside a rack of six equal empty recesses while the girl observes; an open empty chest and an empty shallow tray remain separate on the counter.
The plain card held outside six equal empty rack recesses represents a proposed name being compared with an addressable index space without entering or claiming it. The empty chest and tray show that preparation has not produced a character record or reserved result. These are teaching metaphors, not a key, credential, wallet proof, hash, RPC response, PDA, transaction, reservation, or availability guarantee.

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.

Canonical boy 353 characters · 56 cuboids

NCM2, 8 colors, and 353 UTF-8 bytes before the fixture file's newline.

Canonical girl 451 characters · 70 cuboids

NCM2, 10 colors, and 451 UTF-8 bytes before the fixture file's newline.

Client choice Exactly two fixed strings

The page has no body editor, random generator, upload, or arbitrary-code input in its normal creation flow.

Program validation Kind 1 or 2 · UTF-8 · 1–2,048 bytes · prefix NCM

The program does not decode geometry or prove that a gender label matches a particular body string.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the boy stands in a neutral preview pose on one stone pedestal while the girl waits beside a second empty pedestal; a small layered color sample sits between them.
The occupied and empty pedestals represent switching between two available fixed previews rather than creating a third body. The layered sample represents compact model color data. The cyan edge light is explanatory artwork, not an NCM validator, selected-state proof, mint, upload, or chain confirmation.

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.

Player instruction Tag 8 · six accounts

One compute-budget instruction precedes one upsert_player_appearance instruction in the same transaction.

Profile record player-v7 · 773 bytes

First-creation rent observation: 6,270,960 lamports, or 0.00627096 SOL.

Appearance record appearance-v1 · 9,612 bytes

First-creation rent observation: 67,790,400 lamports; protocol treasury is the stored close authority.

Name record player-name-v1 + hash · 256 bytes

First-creation rent observation: 2,672,640 lamports; the address represents the ASCII-lowercased name identity.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the boy turns one wooden press whose beam reaches three separate work positions: a single block tray, a shaped-name tray, and an open chest containing a layered model; the girl observes with empty hands.
The single press represents one owner-approved instruction, while the three separate work positions represent PlayerProfile, UsernameIndex, and PlayerAppearance. The mechanical linkage illustrates atomic processing only; it is not a transaction builder, signer, account derivation, rent meter, or proof that any Devnet write succeeded.

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.

Transaction wait confirmed, not finalized

The helper returns after its confirmed polling condition; the page does not wait for the finalized label.

Cache timing After the character result, without a storage guard

A setItem failure can enter the outer error path after the chain committed; the interface cannot undo that transaction.

Play identity sync Asynchronous Profile and Appearance reads

The login gateway may skip Appearance, but play/main.js starts a forced chainPlayer refresh that fetches both public records.

Invite timing Separate later transaction

Invite failure is recorded or logged but does not reverse the already confirmed character transaction.

Spawn handoff Best-effort local save, not applied by current Play entry

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the girl stands beside three green-edged record forms inside a timber workshop while a separate orange-edged open case remains in another room and the boy looks toward a distant cyan ground frame outside.
The grouped green records represent the character transaction's related records; the separate orange case represents later invite handling, and the distant cyan frame represents a candidate spawn handoff. Their physical separation teaches that one confirmed result does not prove the others. The colors and objects are not real commitment indicators, localStorage, InviteIndex data, or spawn application evidence.

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 fee

This 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 ≠ finalized

The 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

JavaScript src/chain/nicechunkChain.js
export 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. 1-3

    Normalize the submitted name and reject an empty result before any RPC request.

  2. 4-5

    Derive the canonical name PDA and read exactly that account at confirmed.

  3. 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

JavaScript src/chain/nicechunkChain.js
function 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. 1-5

    Trim the text and enforce the browser's character-count ceiling.

  2. 6-8

    Apply the broad Script=Han-or-ASCII pattern used by the page.

  3. 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

Rust programs/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. 1-6

    Reject an over-budget or invalid UTF-8 byte sequence before inspecting characters.

  2. 7-12

    Count Unicode scalar values and enforce the contract's character ceiling.

  3. 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

JavaScript player_creat/player_creat.js
async 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. 1-3

    Trim the name and stop on a local format failure.

  2. 4-5

    Reuse an in-memory success for the exact same text rather than reading the name account again.

  3. 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

JavaScript player_creat/player_creat.js
function 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. 1-3

    Choose one entry from the fixed array; an out-of-range index is clamped back into that array.

  2. 4-9

    Decode the selected string and expose its label, exact text, UTF-8 byte count, cuboid count, and prefix.

  3. 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

JavaScript player_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. 1-2

    Choose the fixed model and calculate a candidate spawn as separate local preparation.

  2. 3-10

    Load the chain adapter, await the appearance upsert, and turn an explicit not-submitted result into failure.

  3. 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

JavaScript src/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. 1-4

    Derive three different PDAs and map the client gender label to the one-byte model kind.

  2. 5-16

    Build one transaction with a 240,000 compute-unit limit and the exact character payload and accounts.

  3. 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

JavaScript src/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. 1-6

    Encode the instruction tag, normalized model kind, and three byte lengths into an eight-byte header.

  2. 7-16

    Target the Player Program and declare one signer, three writable PDAs, GlobalConfig, and the System Program.

  3. 17-18

    Concatenate the header and exact text bytes into the instruction payload.

An existing name index is validated; a missing one is created

Rust programs/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. 1-7

    Hash the submitted canonical name, derive its exact PDA, and reject a different address.

  2. 8-17

    For an existing Player-owned index, validate its owner and linked records, then return without rewriting it.

  3. 18-28

    Reject a nonempty foreign account; otherwise allocate the missing index at the derived address.

  4. 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

Rust programs/nicechunk_player/src/lib.rs
fn 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. 1-8

    Require exactly the treasury signer, the Appearance account, and the recipient.

  2. 10-27

    Require writable accounts, the configured treasury key, the same key as recipient, and Player Program ownership.

  3. 29-40

    Verify the treasury stored in Appearance and the Appearance PDA derived from its player owner.

  4. 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

Rust programs/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. 1-6

    Accept exactly the two numeric model-kind values; this validates a label byte, not body geometry.

  2. 8-13

    Require the optional title to fit its byte budget and be valid UTF-8.

  3. 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

Rust programs/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. 1-5

    Write the exact model-code bytes and pad the fixed 2,048-byte code region with zeros.

  2. 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

JavaScript src/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. 1-10

    For wallets that sign locally, serialize, submit, poll to confirmed, and return the signature.

  2. 12-17

    For sign-and-send wallets, require support and require an actual returned signature.

  3. 18-23

    Poll that second path to the same confirmed commitment and then return.

Pending spawn and invite receipt saves swallow storage errors

JavaScript src/player/inviteSpawn.js
export 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. 1-12

    Attempt the wallet-scoped pending-spawn write; swallow any storage exception and report no result.

  2. 14-36

    A caller that uses the consumer reads once, removes the draft, normalizes it, and returns null on failure.

  3. 38-49

    Attempt the wallet-scoped invite-receipt write; again swallow storage failure and report no result.

Invite registration constructs and submits another transaction

JavaScript src/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. 1-3

    Choose and derive the inviter's target InviteIndex page and optional previous page.

  2. 4-10

    Build a new transaction containing the invite append accounts and page information.

  3. 11-23

    Submit separately and return a distinct signature and InviteIndex result.

Only the alternate startup path shown here consumes the pending spawn

JavaScript src/main.js
async 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. 1-4

    Consume and normalize a pending invite spawn, then prefer it when valid.

  2. 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

JavaScript login/login.js
async 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. 1-7

    If both local signals exist, run optional invite handling and mark the login interface ready.

  2. 9-13

    Otherwise begin the network path and fetch the wallet's public Appearance account.

  3. 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.