PLAYER, WALLET, AND CONTROL 05 · NAMES, INVITES, AND SPAWN

A unique name, an invite entry, and a spawn hint are three different results

NiceChunk currently carries three ideas through the character journey that are easy to mistake for one promise. A UsernameIndex is a program-owned lookup for one canonical name that persists because the current program exposes no close or release path. An InviteIndex row is a public record appended by the invited wallet into an inviter's paged list. A candidate spawn is browser data calculated from editable URL hints. They have different addresses, signers, storage, failure modes, and readers. The current production Play entry does not consume the pending invite-spawn record, so neither a shared link nor a successful invite append proves where the character will appear.

27 min read
In a generated NiceChunk teaching scene grounded in the canonical villager models and real Chunk.js terrain, the girl stands beside a stone name marker, the boy stands by a separate wooden record cabinet, and an empty cyan ground frame sits farther away on the grass.
The stone marker, wooden cabinet, and empty cyan frame deliberately occupy three separate places. They represent a UsernameIndex, an InviteIndex page, and a candidate spawn. The props are teaching metaphors made from the game's villagers, real terrain reference, and baked-material language; they are not account decoders, wallet controls, signatures, PDAs, on-chain coordinates, or proof that Play applied a spawn.
PLAYER QUESTION If I follow an invite link, choose a unique name, and register successfully, what has actually been proved—and where will I start?
Learning path
Player, wallet, and control · Step 5 of 15
Audience
Players with no programming or blockchain background
Implemented boundary
Editable URL hints · name lookup persistent under the current program · invited-wallet-signed paged record · browser draft · current Play startup
Reviewed
16 July 2026 · current dirty working tree, Player Program source, focused runtime tests, real production Play capture, and finalized Devnet observations
Invite link
An ordinary URL whose query can carry ref, guardian, and guardianRegion text. Anyone who can edit or rebuild the URL can change those fields; the URL contains no inviter signature or secret capability.
Referrer
The public wallet address read from ref, invite, or inviter. The character and login paths may later try to append the current wallet to that address's InviteIndex pages.
Guardian Region
A pair of integer Region coordinates. The current candidate-spawn helper maps the Region center into world coordinates using the 100-by-100-Chunk Guardian Region size and the 16-block Chunk size.
Canonical name
The player name after only ASCII A through Z are changed to lowercase. Other accepted characters are left unchanged before SHA-256 is calculated.
UsernameIndex
A 256-byte Player Program account addressed by the canonical name hash. It links that name address to an owner, PlayerProfile, GlobalConfig, original spelling, and slots.
InviteIndex
A 2,688-byte append-only page for one inviter and one page number. Its 64 fixed records contain only an invited wallet address and registration slot.
Registration slot
The Solana slot number written beside an invited wallet when the append instruction executes. It is not a timestamp, reward state, acceptance message, or spawn coordinate.
PDA
A predictable program-owned account address derived from public seeds. A PDA has no wallet private key; the program checks that callers supplied the expected address.
Page
One fixed-size InviteIndex account. Normal clients fill 64 rows before deriving the next page, but the current program does not make continuous page order an unconditional invariant.
Invited-wallet signature
The append instruction requires the wallet being recorded to sign. That proves this wallet authorized the submitted append; it does not prove the inviter signed that individual row.
Candidate spawn
A position and camera suggestion calculated in the browser from Region information and terrain height. Character creation does not include those coordinates in its Player transaction.
Pending invite spawn
A wallet-scoped localStorage draft written after character confirmation. The save helper catches storage errors and reports no success result, and the current production Play entry does not read it.
Saved Play position
A different, wallet-agnostic localStorage record used by the current Play entry when no query pose or forced Guardian spawn is present. It uses the fixed nicechunk.chunkjs.playable.position.v2 key.
Genesis default
The current Play fallback at the center of Region zero: world X 800 and Z 800 before terrain height is resolved. It is used when no higher-priority query or saved Play position wins.
Rent-exempt minimum
The point-in-time lamport balance needed for an account of a given size. Paying that funding does not itself grant the payer a close instruction or an automatic refund.
Confirmed read
The commitment used by the current browser helpers for name and invite pages. It describes the RPC observation level, not a reservation, signature, finalized result, or guarantee about a later transaction.

Key points

The link supplies editable hints, not authority

ref, guardian, and guardianRegion are public query text. They can help the browser choose an inviter and candidate Region, but they are not signed by the inviter and are not trusted credentials.

A name index persists under the current program, but is not an invite row

The canonical name selects one 256-byte UsernameIndex. Changing the Profile name can create another index, but the current program has no path that automatically closes or releases the old one.

The invited wallet appends itself

Each InviteIndex page holds 64 wallet-and-slot rows. The invited wallet signs the append and may fund a newly rolled page; the inviter does not sign every row, and the contract does not scan for duplicate wallets.

A saved candidate is not the current Play start

The candidate spawn is a best-effort browser draft. Current Play chooses a query pose, its own saved local position, or the Genesis default; it never includes the pending invite-spawn record in that choice.

GLOBAL NAME LOOKUP AND HISTORICAL OCCUPANCY

UsernameIndex makes a name unique, but it neither tracks invitations nor releases old names

A proposed nonempty name is normalized and hashed before its account address is derived. Only ASCII uppercase letters are lowered, so Alice and aLiCe share one hash and one UsernameIndex. The PDA seeds contain the fixed player-name-v1 prefix and the 32-byte hash; they do not contain the owner wallet. That makes the address global within the Player Program rather than one private name slot per player. A confirmed lookup can report that the account is missing or already owned by this wallet, but only an accepted transaction creates or validates the record.

The account is exactly 256 bytes. Its header and fixed fields store magic, version, bump, initialized state, owner, PlayerProfile, GlobalConfig, the 32-byte canonical hash, display-name byte length, created slot, and updated slot. The final 96 bytes hold the original accepted spelling padded with zeros. The contract accepts at most 32 characters from underscore, ASCII letters and digits, or U+4E00 through U+9FFF, so 32 three-byte Chinese characters fit that 96-byte stored spelling region.

When the expected UsernameIndex already belongs to the Player Program, the program validates the hash, GlobalConfig, owner, and PlayerProfile and returns. It does not rewrite the original spelling or updated slot. If the same address names a different owner or profile, the instruction fails with UsernameAlreadyTaken. This is why the index is useful for uniqueness, but it also means the record is historical ownership evidence rather than a guaranteed mirror of the player's current display fields.

Changing a Profile name writes the new name and then ensures the new UsernameIndex inside the same atomic instruction. If the later index check fails, Solana rolls the entire instruction back. If it succeeds, however, there is no close or release of the earlier index. Clearing the Profile name likewise omits an index account and leaves every earlier name address untouched. A wallet can therefore occupy several names it used over time. UsernameIndex contains no inviter, invited-wallet row, Guardian, Region, spawn, reward, or status field, and it cannot substitute for InviteIndex.

The finalized Devnet rent query observed 2,672,640 lamports, or 0.002672640 SOL, for 256 bytes on 16 July 2026. The payer funds a new index or a shortfall during creation, but the current Player Program exposes no UsernameIndex close instruction. Calling that balance a refundable deposit would promise a recovery path that the implemented contract does not provide.

Address identity SHA-256 of ASCII-lowercased name

The owner is stored inside the account but is not one of the name PDA seeds.

Account size 256 bytes

The last 96 bytes retain the original display spelling; the hash uses the canonical spelling.

Rename behavior New index ensured · old index retained

No current tag automatically releases, transfers, or closes the earlier UsernameIndex.

Observed minimum 0.002672640 SOL

A finalized point-in-time Devnet rent-exempt minimum, not a transaction fee or automatic refund.

Invitation fields None

A UsernameIndex cannot prove an invite, append signature, candidate Region, or reward status.

In a generated NiceChunk teaching scene, the canonical girl holds one cyan current marker beside the upper orange plaque of a stone registry while both the upper and lower plaques remain occupied; the canonical boy watches and a separate empty wooden cabinet stands far away.
The girl's single cyan marker represents selecting the newer current name while both fitted name positions remain occupied. The detached empty cabinet represents the separate invite ledger. These plain physical props are explanatory metaphors, not actual hashes, account bytes, owner proofs, close controls, or evidence that the pictured cabinet cells match storage layout.

PAGED PUBLIC RECORDS, SIGNERS, AND LIMITS

The invited wallet signs one append into the inviter's page; the inviter does not sign every row

InviteIndex has a different seed, address formula, byte layout, signer, and purpose from UsernameIndex. One page is derived from invite-index-v1, the inviter's 32-byte public key, and a little-endian u32 page number. Its 128-byte header stores identity and page metadata. Sixty-four fixed 40-byte records follow, each containing exactly one 32-byte invited wallet and one 8-byte registration slot. The account contains no name, Guardian, Region, candidate spawn, local receipt, reward entitlement, claim state, transfer amount, or inviter signature per row.

The inviter normally opens page zero first. Tag 10 requires a writable signing payer, but only page zero requires payer and inviter to be the same address. For pages above zero, any payer can initialize the supplied inviter's correct PDA. The instruction also returns early after validating a page already owned by the Player Program. It never checks that the preceding page exists or is full before independently creating a higher page.

Tag 11 makes the invited wallet the required writable signer and rejects only invited equal to inviter. The inviter account is read as a public address and does not sign. If a requested page above zero is missing, the invited wallet can fund and create it only after the program validates that the immediately preceding page is full. But that continuity check sits entirely inside the missing-page branch. A page pre-created through tag 10 is already program-owned, so append skips the preceding-page test and writes to that page. Continuous 0, 1, 2 paging is therefore the normal client plan, not an unconditional contract invariant.

The append routine reads count, rejects a full page, writes the invited key and slot at offset 128 plus count times 40, increments count, and updates the page slot. It never searches this page or any other page for the wallet. The current browser first reads pages and performs a best-effort duplicate check, but that read and append are separate network operations, two concurrent attempts can race, and rows outside its scan are invisible to that check. The contract prevents self-invite but does not prevent duplicate registration.

Current invite registration and alternate-panel call paths request 16 page addresses, and the reader stops at the first missing, invalid, or not-full page. That means the alternate invite panel can expose at most 1,024 fetched rows, even though fetchInviteIndexPages can accept a larger request of up to 64 pages. After 16 full fetched pages, the registration path constructs page index 16 and can append there, but its next scan still covers only indexes 0 through 15. It keeps selecting index 16 without seeing its count; once that hidden page fills, the current rollover path cannot naturally choose page 17. Gaps and pre-created high pages can also stay invisible because the reader stops early.

At the finalized Devnet snapshot, the Player Program had one observed InviteIndex page at HMbG3jiBpBfFEQmggb5gGyvXhsQzTuCy3zttNSXCpiX. Page zero contained two different wallets in two rows, used 2 of 64 slots, held exactly 19,599,360 lamports, and had no later page through the audit's derived page-63 check. That observation is useful evidence of current state, not proof that duplicates or gaps are impossible. The deployed ELF matched the retained 172,880-byte local artifact, while dirty source still cannot be called reproducibly deployed merely from that match.

Page bytes 2,688 = 128 + 64 × 40

Each row is only an invited public key plus registration slot.

Append signer Invited wallet

The inviter is supplied as a non-signing address for each append.

Protocol duplicate scan None

The client attempts a prior read-and-check, but the program's append itself does not enforce uniqueness.

Current browser scan 16 pages · 1,024 nominal rows

The helper stops early at a gap or partial page and cannot see index 16 in its default read.

Observed page minimum 0.019599360 SOL

Finalized Devnet minimum for 2,688 bytes at the recorded query, with no InviteIndex close path implemented.

Observed live rows 2 unique wallets · page 0 only

A dated snapshot at finalized slots, not a lifetime count or a uniqueness guarantee.

  1. Inviter opens page zero

    The current first-page helper asks the inviter wallet to sign and fund its page-zero PDA before invitees can append.

  2. Invitee reads visible pages

    The browser fetches up to sixteen predictable page addresses, stops at the first boundary, and checks the fetched rows for its own wallet.

  3. Invitee approves append

    The invited wallet signs tag 11. If normal rollover must create a missing page, that same invited wallet also funds the new page.

  4. Readers reconstruct meaning

    A reader may separately fetch Appearance to display a name, but the fixed InviteIndex row itself remains just wallet plus slot.

In a generated NiceChunk teaching scene, the canonical boy presses his own cyan-highlighted token into a large paged wooden cabinet while the canonical girl observes with hands away; an identical boy token and a separate later cabinet are also visible.
The boy acting while the girl keeps her hands away represents the invited wallet signing its own append while the inviter does not sign that row. Matching tokens represent the lack of contract-level duplicate scanning, and the detached cabinet represents an independently existing higher page. The cabinet grid is intentionally only a visual paging metaphor: its pictured cell count is not technical evidence. The exact implemented capacity is the 64-row formula and source shown below.

LOCAL DRAFT, CURRENT STARTUP, AND POSITION AUTHORITY

Character creation saves a candidate spawn, but current Play never puts it into the startup choice

After the owner-signed character transaction returns, character creation writes ordinary character cache fields and calls storePendingInviteSpawn with the candidate position, yaw, camera pitch, Region, referrer, and Guardian identifier. That helper uses a wallet-scoped nicechunk.inviteSpawn.v1.<wallet> key, adds storedAt, catches any localStorage exception, and returns no success value. A full, blocked, disabled, or privacy-restricted storage implementation can therefore discard the draft while the page continues. The candidate is not a Player Program account and is not part of tag 8.

A consumer function exists in src/player/inviteSpawn.js. It reads the wallet-scoped draft, removes it before parsing, and returns either the stored position or a recalculated Region center. It has no age or expiry check. Its catch block calls localStorage.removeItem again outside another nested guard, so a storage implementation that throws on both read and removal can still let the cleanup exception escape. The focused tests record single-use behavior, malformed cleanup, and this double-failure boundary.

Defining a consumer does not prove that the active game calls it. The current production entry is play/main.js. Its imports include its own saved-position helper and pose parser, but not consumePendingInviteSpawn. At module startup it chooses queryPose, otherwise the wallet-agnostic saved Play position, otherwise a fixed Genesis center. Direct x, y, and z query values can then override axes. A forced Guardian-start flag skips the saved position but still falls through to the hard-coded Region-zero default. The pending invite-spawn key never appears in that selection.

The dated production capture at https://nicechunk.com/play/ used the then-current production runtime, the nicechunk-mainnet-001 world seed, and explicit X 800 and Z 800 at the Genesis center. The retained image is a real game-canvas capture, not generated teaching art. A later current-runtime browser recheck at 15:03:35 UTC on 16 July 2026 stored a sentinel pending-spawn value before boot; that value remained afterward, the page exposed no invite panel, three canvases were present, and no console or page error was observed. Those dated observations match the source boundary but do not prove what every future build will do.

Play later starts an asynchronous Profile and Appearance refresh. That can update identity presentation and provide public Profile position data to the interface, but the audited startup does not teleport the already-created player object to that fetched Profile. Normal local movement uses a different saved-position key, and current chain position writes are gated to accepted resource-mine saves. An invite append, local receipt, Profile refresh, and actual position are therefore separate state transitions.

Pending key nicechunk.inviteSpawn.v1.<wallet>

Wallet-scoped best-effort draft written after character confirmation.

Current Play saved key nicechunk.chunkjs.playable.position.v2

A different wallet-agnostic local position selected before the game boots.

Current startup priority Query pose → saved Play position → Genesis

Pending invite spawn is absent; per-axis x, y, and z query parameters can override the resulting coordinates.

Genesis center X 800 · Z 800

Half of 100 Chunks multiplied by 16 blocks for both axes.

Draft expiry No age check

A runtime that does call the consumer can accept an old draft unless another layer clears it.

Current production consumption Not implemented in play/main.js

An alternate src/main.js path consumes it, but that is not the reviewed production Play entry.

In one continuous generated NiceChunk landscape, the canonical girl stands beside an empty cyan candidate square and an unused tile in a wooden tray, while the canonical boy stands far away from behind on pale stepped Genesis terrain.
The empty cyan ground frame and unused tray tile remain in the same continuous world while the boy stands separately on pale terrain grounded in the dated real Genesis capture. Their physical separation represents a candidate draft that is absent from the current startup choice. This generated scene is not a browser trace, teleport attempt, account position, guarantee that storage succeeded, or proof that every player starts at Genesis when a query or saved Play position exists.

READ THE IMPLEMENTATION

Derive each address, inspect each signer, and keep browser state outside contract claims

The formulas and exact source excerpts below turn the visual lesson into reproducible checks. They show how URL text becomes an unauthenticated hint, how names and invite pages derive different PDAs, how fixed bytes are laid out, why duplicate and page-continuity claims have limits, and why the current Play startup cannot apply a draft it never reads.

Canonical name and UsernameIndex address

canonical(n) = ASCII-lowercase(n); h(n) = SHA256(UTF8(canonical(n))); UsernameIndex(n) = PDA_Player(["player-name-v1", h(n)])

The owner wallet is not a seed, so everyone derives the same address for Alice and alice. Ownership is checked in the stored bytes when the instruction executes.

n
The trimmed proposed player name.
ASCII-lowercase
Change only ASCII A through Z to a through z; leave other UTF-8 characters unchanged.
PDA_Player
Solana program-address derivation under the Player Program ID.

UsernameIndex fixed storage

256 bytes = 160 bytes header-and-fields + 96 bytes original display-name region

The account also stores the canonical 32-byte hash in the fixed region. The original spelling is retained separately and is not automatically rewritten on a same-index validation.

160
Magic, version, bump, state, three public keys, name hash, length, slots, and padding.
96
Maximum UTF-8 bytes retained for the accepted display spelling.

Invite page address

InvitePage(inviter, i) = PDA_Player(["invite-index-v1", inviter32, u32LE(i)])

Changing either the inviter or page number selects a different predictable account. A predictable address does not prove the page exists, is continuous, or was opened by the inviter.

inviter32
The inviter's 32-byte Solana public key.
u32LE(i)
The page number encoded as four little-endian bytes.

InviteIndex page and row layout

pageBytes = 128 + 64 × 40 = 2,688; rowOffset(k) = 128 + 40k, for 0 ≤ k < 64

Every row is one 32-byte wallet plus one 8-byte registration slot. The illustration's wooden cubbies are not a countable storage diagram; this formula and the source constants are the technical boundary.

128
Fixed page header.
40
One invited-wallet and registration-slot record.
k
The zero-based row selected by the current count.

Normal contiguous paging convention

page(k) = floor(k / 64); row(k) = k mod 64

This is the intended client organization when pages are created only during rollover. Tag 10 can pre-create a higher page without proving prior pages are full, so this formula is not an unconditional current contract invariant.

k
A conceptual zero-based entry number in a gap-free ledger.
mod
Remainder after division by 64.

Candidate Region-center position

x = (regionX × 100 + 50) × 16; z = (regionY × 100 + 50) × 16; y = surfaceHeight(x, z) + 1.01

The 100 comes from Guardian Region size in Chunks and 16 from Chunk size in blocks. This is local candidate math; no InviteIndex row stores these coordinates.

regionX, regionY
Integer Guardian Region coordinates.
surfaceHeight
The current browser world generator's terrain-height result at that X and Z.

Current production Play startup

spawnState = queryPose ?? savedPlayPosition ?? Genesis(800, 800); pendingInviteSpawn is not an operand

Direct x, y, and z query parameters can then override axes. The Profile refresh starts later and does not retroactively change this startup expression.

??
Choose the first value that is not null or undefined.
savedPlayPosition
The separate nicechunk.chunkjs.playable.position.v2 browser record.

Observed Devnet storage minima

R(256) = 2,672,640 lamports; R(2,688) = 19,599,360 lamports; 1 SOL = 1,000,000,000 lamports

These are finalized point-in-time minimum-balance queries. They are not transaction fees, reward values, or promises that a player can close the account and recover the balance.

R(L)
Observed minimum balance for an account of L bytes.
lamport
The smallest native SOL unit.

The parser defaults genesis before it computes hasInvite

JavaScript src/player/inviteSpawn.js
export function parseInviteParams(input = typeof window !== "undefined" ? window.location.search : "") {
  const params = input instanceof URLSearchParams ? input : new URLSearchParams(String(input || ""));
  const referrer = firstParam(params, [inviteRefParam, "invite", "inviter"]);
  const guardianId = firstParam(params, [inviteGuardianParam, "guardianId"]) || "genesis";
  const region = parseGuardianRegionParams(params);
  return {
    referrer: normalizeParam(referrer),
    guardianId: normalizeParam(guardianId) || "genesis",
    region,
    hasInvite: Boolean(normalizeParam(referrer) || normalizeParam(guardianId) || region),
  };
}

guardianId is already genesis when the URL contains no Guardian field, so the Boolean expression is true on an empty query. Nothing in this function verifies an inviter signature or capability.

  1. 1-4

    Normalize URL input, read public referrer aliases, and replace a missing Guardian with genesis.

  2. 5-11

    Return normalized fields; the already-nonempty default makes hasInvite true even without supplied invite text.

Missing coordinate strings currently become Region zero

JavaScript src/player/inviteSpawn.js
function parseGuardianRegionParams(params) {
  const explicit = firstParam(params, [inviteGuardianRegionParam, "region"]);
  const parsedExplicit = parseRegionPair(explicit);
  if (parsedExplicit) return parsedExplicit;
  const guardian = firstParam(params, [inviteGuardianParam, "guardianId"]);
  const parsedGuardian = parseRegionPair(guardian);
  if (parsedGuardian) return parsedGuardian;
  const x = Number(firstParam(params, ["guardianX", "regionX"]));
  const y = Number(firstParam(params, ["guardianY", "regionY"]));
  if (Number.isInteger(x) && Number.isInteger(y)) return { regionX: x, regionY: y };
  return null;
}

firstParam returns an empty string for each missing coordinate. JavaScript Number converts that empty string to zero, and zero is an integer, so a plain visit currently returns Region 0:0.

  1. 1-7

    Prefer explicit pair text, then a coordinate-looking Guardian value.

  2. 8-11

    Convert separate coordinate fields; the missing-string conversion is the empty-query defect.

The candidate spawn is local Region-center math

JavaScript src/player/inviteSpawn.js
export function guardianSpawnStateForRegion(region, { surfaceHeight, yaw = Math.PI * 0.25, cameraPitch = -0.42 } = {}) {
  const normalized = normalizeGuardianRegion(region) ?? genesisGuardianRegion();
  const centerChunkX = normalized.regionX * GUARDIAN_REGION_SIZE + GUARDIAN_REGION_SIZE / 2;
  const centerChunkZ = normalized.regionY * GUARDIAN_REGION_SIZE + GUARDIAN_REGION_SIZE / 2;
  const x = centerChunkX * chunkSize;
  const z = centerChunkZ * chunkSize;
  const ground = typeof surfaceHeight === "function" ? surfaceHeight(x, z) : 0;
  const y = Number.isFinite(ground) ? ground + 1.01 : 1.01;
  return {
    position: { x, y, z },
    yaw,
    cameraPitch,
    guardianRegion: normalized,
  };
}

The helper returns an ordinary JavaScript object from public Region input and local terrain math. It does not read an InviteIndex, sign a transaction, or update PlayerProfile position.

  1. 1-6

    Normalize or fall back to Genesis, choose the Region center in Chunks, then convert to block coordinates.

  2. 7-14

    Resolve local ground height and return position plus camera hints.

The client hashes only ASCII case before deriving the name PDA

JavaScript src/chain/nicechunkChain.js
async function canonicalPlayerNameHash(playerName) {
  const { normalized } = encodePlayerName(playerName);
  const canonical = normalized.replace(/[A-Z]/g, (char) => char.toLowerCase());
  const bytes = Buffer.from(canonical, "utf8");
  const subtle = globalThis.crypto?.subtle;
  if (!subtle) throw new Error("WebCrypto SHA-256 is unavailable.");
  const digest = await subtle.digest("SHA-256", bytes);
  return Buffer.from(new Uint8Array(digest));
}

The browser and program use the same narrow case rule. SHA-256 produces the 32-byte seed, but hashing does not reserve an account or reveal who owns it.

  1. 1-4

    Validate and trim the name, lower only ASCII uppercase letters, and encode UTF-8 bytes.

  2. 5-8

    Require WebCrypto, calculate SHA-256, and return the digest bytes used for PDA derivation.

The name PDA has no owner seed

JavaScript src/chain/nicechunkChain.js
export async function deriveUsernameIndexPdaForName(playerName) {
  const nameHash = await canonicalPlayerNameHash(playerName);
  return PublicKey.findProgramAddressSync(
    [Buffer.from(usernameIndexSeed), nameHash],
    playerProgramId,
  );
}

Every caller derives the same Player Program address from the fixed seed and canonical name hash. The owner relationship must therefore be validated from account contents.

  1. 1-2

    Calculate the canonical name hash.

  2. 3-6

    Derive under the Player Program with only the fixed name seed and hash.

An existing UsernameIndex must match owner and Profile

Rust programs/nicechunk_player/src/state.rs
    pub fn validate_owner_or_available(
        data: &[u8],
        owner: &Pubkey,
        player_profile: &Pubkey,
        global_config: &Pubkey,
        name_hash: &[u8; 32],
    ) -> ProgramResult {
        if data.len() != Self::LEN || data[0..8] != USERNAME_INDEX_MAGIC {
            return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
        }
        if read_u16(data, 8) != USERNAME_INDEX_VERSION {
            return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
        }
        if &data[Self::NAME_HASH_OFFSET..Self::NAME_HASH_OFFSET + 32] != name_hash {
            return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
        }
        if &data[Self::GLOBAL_CONFIG_OFFSET..Self::GLOBAL_CONFIG_OFFSET + 32]
            != global_config.as_ref()
        {
            return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
        }
        if &data[Self::OWNER_OFFSET..Self::OWNER_OFFSET + 32] != owner.as_ref() {
            return Err(NicechunkPlayerError::UsernameAlreadyTaken.into());
        }
        if &data[Self::PLAYER_PROFILE_OFFSET..Self::PLAYER_PROFILE_OFFSET + 32]
            != player_profile.as_ref()
        {
            return Err(NicechunkPlayerError::UsernameAlreadyTaken.into());
        }
        Ok(())
    }

A program-owned index is accepted only when its format, hash, configuration, owner, and Profile relationship all match. The function validates; it does not rewrite spelling, release a prior index, or inspect invitations.

  1. 1-16

    Check the fixed account identity, version, canonical hash, and GlobalConfig.

  2. 17-29

    Treat a different stored owner or PlayerProfile as taken; accept only the matching relationship.

Changing Profile name ensures a new index but closes no old index

Rust programs/nicechunk_player/src/lib.rs
    let clock = Clock::get()?;
    let mut data = player_profile.try_borrow_mut_data()?;
    PlayerProfile::write_name(&mut data, player_name, clock.slot)?;
    drop(data);
    if let (Some(system_program_account), Some(username_index)) =
        (system_program_account, username_index)
    {
        ensure_username_index_current(
            authority,
            username_index,
            system_program_account,
            program_id,
            authority.key,
            player_profile.key,
            global_config.key,
            player_name,
            &clock,
        )?;
    }
    Ok(())

The instruction writes the current Profile name and then ensures the supplied new-name index. Solana rolls both back if the later check fails. On success, this path never receives or closes the earlier UsernameIndex, so its lamports and ownership remain.

  1. 1-4

    Write the proposed current name and update slot in the borrowed Profile data.

  2. 5-18

    For a nonempty name, create or validate only the supplied current-name index.

  3. 19

    Return without any earlier-name account or close operation in this instruction.

Invite pages have a fixed 64-by-40-byte record area

Rust programs/nicechunk_player/src/state.rs
pub const INVITE_INDEX_MAGIC: [u8; 8] = *b"NCKINV01";
pub const INVITE_INDEX_VERSION: u16 = 1;
pub const INVITE_INDEX_SEED: &[u8] = b"invite-index-v1";
pub const INVITE_INDEX_CAPACITY: usize = 64;
pub const INVITE_INDEX_HEADER_LEN: usize = 128;
pub const INVITE_INDEX_RECORD_LEN: usize = 40;

The constants define the identity and exact storage math. A visual cabinet is only a metaphor; the implemented page is 128 header bytes plus 64 fixed records.

  1. 1-3

    Name the on-chain format, version, and PDA seed.

  2. 4-6

    Fix capacity, header length, and record length used by pack, validate, and append.

Append writes the next wallet and slot without deduplication

Rust programs/nicechunk_player/src/state.rs
    pub fn append(dst: &mut [u8], invited: &Pubkey, updated_slot: u64) -> ProgramResult {
        if dst.len() != Self::LEN || dst[0..8] != INVITE_INDEX_MAGIC {
            return Err(NicechunkPlayerError::InvalidInviteIndexData.into());
        }
        let count = read_u16(dst, Self::COUNT_OFFSET) as usize;
        if count >= INVITE_INDEX_CAPACITY {
            return Err(NicechunkPlayerError::InviteIndexPageFull.into());
        }
        let offset = Self::RECORDS_OFFSET + count * INVITE_INDEX_RECORD_LEN;
        dst[offset..offset + 32].copy_from_slice(invited.as_ref());
        dst[offset + 32..offset + 40].copy_from_slice(&updated_slot.to_le_bytes());
        dst[Self::COUNT_OFFSET..Self::COUNT_OFFSET + 2]
            .copy_from_slice(&((count + 1) as u16).to_le_bytes());
        dst[Self::UPDATED_SLOT_OFFSET..Self::UPDATED_SLOT_OFFSET + 8]
            .copy_from_slice(&updated_slot.to_le_bytes());
        Ok(())
    }

After format and capacity checks, append writes directly at the count-selected offset. No line scans the current or other pages for the invited public key.

  1. 1-8

    Validate page identity, read count, and stop only when capacity is exhausted.

  2. 9-15

    Write the 32-byte wallet and 8-byte slot, increment count, and update the page slot.

  3. 16-17

    Return success without a duplicate-wallet branch.

Tag 10 restricts page zero but can independently open higher pages

Rust programs/nicechunk_player/src/lib.rs
    if !payer.is_signer || !payer.is_writable {
        return Err(NicechunkPlayerError::InvalidPayer.into());
    }
    if !invite_index.is_writable {
        return Err(NicechunkPlayerError::InvalidWritableAccount.into());
    }
    if page_index == 0 && payer.key != inviter.key {
        return Err(NicechunkPlayerError::InviteFirstPageRequiresInviter.into());
    }
    require_key_eq(
        system_program_account.key,
        &system_program::ID,
        NicechunkPlayerError::InvalidSystemProgram,
    )?;
    require_key_eq(
        global_config.owner,
        &NICECHUNK_CORE_PROGRAM_ID,
        NicechunkPlayerError::InvalidGlobalConfigOwner,
    )?;

    let (expected_invite_index, bump) = invite_index_pda(program_id, inviter.key, page_index);
    require_key_eq(
        invite_index.key,
        &expected_invite_index,
        NicechunkPlayerError::InvalidInviteIndexPda,
    )?;
    if invite_index.owner == program_id {
        let data = invite_index.try_borrow_data()?;
        return InviteIndex::validate(&data, inviter.key, global_config.key, page_index);
    }

The payer signs, and page zero alone binds payer to inviter. For a correct higher-page PDA, this excerpt has no previous-page check. An already-created page is simply validated and returned.

  1. 1-9

    Require a signing payer and writable page; require inviter self-funding only for page zero.

  2. 10-26

    Validate system, config owner, and the inviter-plus-page PDA.

  3. 27-30

    If the page already exists under the Player Program, validate it without any continuity test.

Tag 11 requires the invited wallet—not the inviter—to sign

Rust programs/nicechunk_player/src/lib.rs
    if !invited.is_signer || !invited.is_writable {
        return Err(NicechunkPlayerError::InvalidPayer.into());
    }
    if invited.key == inviter.key {
        return Err(NicechunkPlayerError::InvalidInviteSelf.into());
    }
    if !invite_index.is_writable {
        return Err(NicechunkPlayerError::InvalidWritableAccount.into());
    }

The wallet being recorded must sign and can pay for rollover. The inviter is compared as a public key for self-invite but is not required to be a signer in this account check.

  1. 1-3

    Require the invited account to sign and be writable.

  2. 4-9

    Reject self-invite and require the target page to be writable.

The previous-full rule runs only when the target page is missing

Rust programs/nicechunk_player/src/lib.rs
    if invite_index.owner != program_id {
        if page_index == 0 {
            return Err(NicechunkPlayerError::InviteFirstPageRequiresInviter.into());
        }
        if accounts.len() != 6 {
            return Err(NicechunkPlayerError::InvitePreviousPageRequired.into());
        }
        let previous_invite_index = next_account_info(account_info_iter)?;
        let previous_page_index = page_index.saturating_sub(1);
        let (expected_previous, _) = invite_index_pda(program_id, inviter.key, previous_page_index);
        require_key_eq(
            previous_invite_index.key,
            &expected_previous,
            NicechunkPlayerError::InvalidInviteIndexPda,
        )?;
        require_key_eq(
            previous_invite_index.owner,
            program_id,
            NicechunkPlayerError::InvalidInviteIndexData,
        )?;
        let previous_data = previous_invite_index.try_borrow_data()?;
        if !InviteIndex::is_full(
            &previous_data,
            inviter.key,
            global_config.key,
            previous_page_index,
        )
        .map_err(|_| NicechunkPlayerError::InvalidInviteIndexData)?
        {
            return Err(NicechunkPlayerError::InvitePreviousPageNotFull.into());
        }
        drop(previous_data);
        if invite_index.owner != &system_program::ID || invite_index.data_len() != 0 {
            return Err(NicechunkPlayerError::InvalidSystemAccount.into());
        }
        create_or_allocate_invite_index_pda(
            invited,
            invite_index,
            system_program_account,
            program_id,
            inviter.key,
            page_index,
            bump,
        )?;
        let clock = Clock::get()?;
        {
            let mut data = invite_index.try_borrow_mut_data()?;
            InviteIndex::pack_empty(
                &mut data,
                &InviteIndexInitArgs {
                    bump,
                    inviter: inviter.key,
                    global_config: global_config.key,
                    page_index,
                    created_slot: clock.slot,
                },
            )?;
        }
    }

    let clock = Clock::get()?;
    let mut data = invite_index.try_borrow_mut_data()?;
    InviteIndex::validate(&data, inviter.key, global_config.key, page_index)?;
    InviteIndex::append(&mut data, invited.key, clock.slot)

Normal rollover checks and funds a missing higher page only after the prior page is full. If tag 10 already made the target Player-owned, the outer if is false and execution jumps to validate and append, bypassing continuity.

  1. 1-31

    For a missing page, forbid page zero, demand the previous page, validate it, and require it to be full.

  2. 32-58

    Require an untouched System account, let the invited wallet fund it, and pack the new page.

  3. 61-64

    For both new and already-existing pages, validate the target and append; an existing high page reached this point without the prior-full branch.

The browser reader scans a bounded contiguous prefix

JavaScript src/chain/nicechunkChain.js
export async function fetchInviteIndexPages(inviterAddress, { maxPages = 16 } = {}) {
  if (!inviterAddress) return { inviter: "", pages: [], entries: [], capacity: inviteIndexCapacity };
  const inviter = typeof inviterAddress === "string" ? new PublicKey(inviterAddress) : inviterAddress;
  const pageCount = Math.max(1, Math.min(64, Math.floor(Number(maxPages) || 16)));
  const pageKeys = Array.from({ length: pageCount }, (_value, index) => deriveInviteIndexPda(inviter, index)[0]);
  const accounts = await getNicechunkConnection().getMultipleAccountsInfo(pageKeys, "confirmed");
  const pages = [];
  const entries = [];
  let sawMissingAfterData = false;
  for (let index = 0; index < pageKeys.length; index += 1) {
    const account = accounts[index];
    if (!account?.data?.length || !account.owner.equals(playerProgramId)) {
      if (pages.length) sawMissingAfterData = true;
      if (!pages.length || sawMissingAfterData) break;
      continue;
    }
    const page = decodeInviteIndex(account.data, {
      publicKey: pageKeys[index],
      inviter,
      pageIndex: index,
      programId: account.owner,
    });
    if (!page) break;
    pages.push(page);
    for (const entry of page.entries) entries.push(entry);
    if (page.count < inviteIndexCapacity) break;
  }

The default request derives only indexes 0 through 15 and stops at a missing, invalid, or not-full page. A valid page beyond that stopping point can exist without appearing in the returned ledger.

  1. 1-6

    Clamp the requested count and fetch one predictable prefix at confirmed.

  2. 7-16

    Walk in order and stop as soon as a missing boundary is observed.

  3. 17-26

    Decode, accumulate, and stop again when the current page is not full.

Client deduplication is a read-before-write convenience

JavaScript src/chain/nicechunkChain.js
  const current = await fetchInviteIndexPages(inviter, { maxPages: 16 });
  if (!current.pages.length) {
    return { submitted: false, reason: "invite-first-page-required", inviter: inviter.toBase58() };
  }
  const invitedWallet = provider.publicKey.toBase58();
  if (current.entries.some((entry) => String(entry.invitedWallet || entry.wallet || "") === invitedWallet)) {
    return {
      submitted: false,
      reason: "already-registered",
      invited: invitedWallet,
      inviter: inviter.toBase58(),
      capacity: inviteIndexCapacity,
    };
  }
  let targetPage = current.pages.find((page) => page.count < inviteIndexCapacity);
  if (!targetPage) {
    const nextIndex = current.pages.length;
    const [publicKey] = deriveInviteIndexPda(inviter, nextIndex);
    targetPage = { pageIndex: nextIndex, publicKey: publicKey.toBase58(), count: 0 };
  }

The browser checks only rows returned by the bounded read, then separately submits later. This avoids common repeats but cannot provide atomic or protocol-wide uniqueness, and nextIndex is based only on the number of visible pages.

  1. 1-4

    Read at most sixteen pages and require a visible first page.

  2. 5-14

    Return early when the invited wallet appears in the fetched prefix.

  3. 15-20

    Use a visible partial page or derive the next index from visible page count.

The decoder invents the display status registered

JavaScript src/chain/nicechunkChain.js
  const entries = [];
  for (let index = 0; index < count; index += 1) {
    const offset = inviteIndexHeaderLength + index * inviteIndexRecordLength;
    const invited = new PublicKey(data.subarray(offset, offset + 32)).toBase58();
    entries.push({
      invitedWallet: invited,
      wallet: invited,
      pageIndex: storedPageIndex,
      index,
      createdSlot: data.readBigUInt64LE(offset + 32).toString(),
      status: "registered",
    });
  }

The bytes provide the wallet and slot. status is a JavaScript label attached during decoding, not a field that the program packed or a reward state that the account can enforce.

  1. 1-4

    For each stored count, calculate the fixed record offset and read the public key.

  2. 5-12

    Build a display object and add a registered label that was not stored in the row bytes.

Pending-spawn storage is best effort and consumption is single use

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;
  }
}

Saving catches failures and returns no persistence signal. A caller that uses the consumer removes before parsing and performs no storedAt age check; cleanup can itself throw if storage removal fails inside the catch.

  1. 1-12

    Attempt a wallet-scoped JSON write with storedAt, then silently continue on failure.

  2. 14-31

    Read once, remove immediately, parse a stored position or recalculate from Region, and return it.

  3. 32-35

    Try removal again on failure; the second removal is not protected by another nested catch.

Current Play chooses query, its own saved position, or Genesis

JavaScript play/main.js
const queryPose = parsePoseText(params.get("pose") || "", { minViewDistance: 2, maxViewDistance: PLAYABLE_MAX_VIEW_DISTANCE });
const PLAYABLE_WORLD_SEED = "nicechunk-mainnet-001";
const PLAYABLE_TEXTURE_TILE_SIZE = 32;
const viewDistance = clampInt(Number(params.get("view")) || queryPose?.viewDistance || DEFAULT_PLAY_VIEW_DISTANCE, 2, PLAYABLE_MAX_VIEW_DISTANCE);
const meshBudgetMs = clampInt(Number(params.get("budget")) || DEFAULT_MESH_BUDGET_MS, 2, 14);
const POSITION_STORAGE_KEY = "nicechunk.chunkjs.playable.position.v2";
const POSITION_SAVE_INTERVAL_MS = 650;
const FRAME_MINIMAP_UPDATE_MS = 250;
const FRAME_ACTION_HIT_UPDATE_MS = 90;
const forceGuardianSpawn = params.get("guardianSpawn") === "1" || params.get("spawn") === "guardian";
const defaultGuardianSpawn = {
  worldX: (GENESIS_GUARDIAN_REGION_SIZE_CHUNKS / 2) * 16,
  worldY: undefined,
  worldZ: (GENESIS_GUARDIAN_REGION_SIZE_CHUNKS / 2) * 16,
  localOffsetX: 0.5,
  localOffsetY: 0,
  localOffsetZ: 0.5,
  controlYaw: Math.PI * 0.25,
  avatarYaw: Math.PI * 0.25,
  cameraPitch: -0.42,
};
const savedSpawn = (queryPose || hasSpawnParam(params) || forceGuardianSpawn) ? null : loadSavedPlayerPosition({ storageKey: POSITION_STORAGE_KEY, seed: PLAYABLE_WORLD_SEED });
const spawnState = queryPose ?? savedSpawn ?? defaultGuardianSpawn;
const spawnX = spawnCoord(params, "x", spawnState?.worldX ?? 0);
const spawnYOverride = spawnCoordOrNull(params, "y", spawnState?.worldY);
const spawnZ = spawnCoord(params, "z", spawnState?.worldZ ?? 0);
const spawnFlightEnabled = Boolean(spawnState?.flightEnabled || params.get("fly") === "1" || params.get("flight") === "1");

The active startup expression never references the wallet-scoped pending invite key or its consumer. Genesis X and Z resolve to 50 times 16, or 800, when no higher-priority state wins.

  1. 1-10

    Parse optional pose and runtime controls, then define a separate Play position key and forced-default flag.

  2. 11-21

    Construct the fixed Region-zero center and camera defaults.

  3. 22-27

    Skip saved state for explicit queries or forced default, choose query then saved then Genesis, and finally apply axis and flight query values.

IMPLEMENTATION EVIDENCE

Where these claims come from

Each claim is intentionally scoped to a concrete implementation path. These references are for verification, not decoration.

src/player/inviteSpawn.js

Defines editable invite parameter parsing, the current empty-query Region-zero behavior, Guardian Region center math, wallet-scoped pending-spawn and receipt keys, best-effort saving, single-use consumption, and the no-expiry and cleanup boundaries.

player_creat/player_creat.js

Carries invite hints through redirects, calculates a candidate before the character call, writes browser caches after character confirmation, separately attempts InviteIndex registration, and redirects even when invite registration did not submit.

login/login.js

Attempts the same separate invite registration for an already-created character and stores a browser receipt whose pending or registered label is not an InviteIndex field.

src/chain/nicechunkChain.js

Derives UsernameIndex and InviteIndex PDAs, performs confirmed name and paged invite reads, decodes fixed layouts, applies bounded client deduplication, initializes page zero, constructs the invited-wallet append, and exposes point-in-time rent queries.

src/play/features/profile/invitePanel.js

Contains the alternate profile invite panel that creates share links, initializes page zero, requests at most sixteen InviteIndex pages, paginates fetched entries, and labels values for display; this panel is not present in the audited current production Play entry.

play/index.html

Defines the current production Play interface audited for invite-panel presence; the expected profileInviteOpen and profileInvitePanel elements are absent.

play/main.js

Is the current production Play entry, selects query pose, separate saved Play position, or Genesis before boot, starts Profile refresh later, and neither imports nor calls consumePendingInviteSpawn.

play/position-persistence.js

Reads and writes the distinct wallet-agnostic saved Play position, validates version and world seed, throttles writes, and catches ordinary storage failures.

src/main.js

Contains an alternate startup path that does consume the pending invite spawn, demonstrating why a consumer's existence elsewhere must not be attributed to play/main.js.

programs/nicechunk_player/src/lib.rs

Routes name, invite-page initialization, and invite append tags; verifies account addresses and signers; requires the inviter for page zero; allows independent higher-page initialization; checks previous-full only while append creates a missing page; and exposes no UsernameIndex or InviteIndex close path.

programs/nicechunk_player/src/state.rs

Defines exact name and invite seeds, lengths, layouts, validation, fixed 64-row page capacity, wallet-plus-slot append, narrow accepted name range, and absence of duplicate scanning in InviteIndex append.

programs/nicechunk_player/src/errors.rs

Defines explicit self-invite, first-page, previous-page, full-page, invalid-data, and name-taken errors while providing no duplicate-registration error.

target/deploy/nicechunk_player.so

The retained 172,880-byte artifact matched the finalized Devnet ProgramData ELF hash in the dated audit; that byte match does not prove dirty source reproducibility.

docs/audits/names-invites-and-spawn-devnet-2026-07-16.json

Records finalized Devnet program identity, deployed ELF comparison, rent minima, the complete observed InviteIndex scan, derived page-zero-through-63 continuity check, two current rows, source hashes, production runtime boundary, and explicit interpretation limits.

play/tests/names-invites-and-spawn.test.mjs

Locks seven current local behaviors: empty-query fallback, Region priority and serialization, center math, wallet-scoped storage, single consumption, malformed cleanup, and the cleanup rethrow when both storage read and removal fail.

docs/visual-provenance/names-invites-and-spawn.json

Records canonical villager models, real Chunk.js hillside, real Genesis capture, baked-material reference, selected imagegen outputs, prompts, hashes, dimensions, review decisions, and the limits of every visual metaphor.

public/media/vox/chr_peasant_guy_blackhair.ncm

Provides the canonical NiceChunk villager boy model used as an identity reference for all generated teaching images.

public/media/vox/chr_peasant_girl_orangehair.ncm

Provides the canonical NiceChunk villager girl model used as an identity reference for all generated teaching images.