PLAYER, WALLET, AND CONTROL · STEP 10

Skills, XP, and Progress: The Authoritative On-Chain Path

NiceChunk stores all ten XP totals and levels in one owner-scoped PlayerSkills PDA. A treasury-controlled SkillRuleTable defines cumulative thresholds and rules that convert validated counters from Chunk, Smelting, Player, and Backpack state into XP. Mining, Smelting, and Forging synchronize those counters on chain; Profile reads PlayerSkills and the same on-chain thresholds. Browser behavior counters and localStorage are not XP authorities, fallbacks, or merge candidates.

36 min read
Generated NiceChunk coastal training workshop where the canonical villagers compare source activity stations with one central stone skill ledger and a ten-step progression stair.
The stations and central archive are teaching metaphors for program-owned source counters, PlayerSkills, and SkillRuleTable thresholds. The illustration does not prove an account address, XP amount, transaction result, rule revision, or level.
PLAYER QUESTION When an action succeeds, which account owns my XP, how is a level calculated, and when will Profile show the result?
Learning path
Player, wallet, and control · Step 10 of 15
Audience
Players and integrators who need the current on-chain progression model
Production entry reviewed
/play/index.html → /play/main.js → play/* → src/chain/nicechunkChain.js → Skills program
Reviewed
25 July 2026 · current source, focused browser tests, Rust tests, and Devnet transaction probes
PlayerSkills
The 480-byte Skills-program PDA that stores one owner's ten u64 XP totals, ten cached levels, source cursors, rule revision, mining position, cumulative Burden awards, and the last consumed mine sequence.
SkillRuleTable
The Skills-program PDA controlled by the configured authority. It stores ten cumulative threshold arrays, generic source rules, the mining travel rule, the burden rule, and one revision.
Source counter
A monotonic value in a validated program-owned account, such as Chunk Precision XP, Game/Smelting XP, Player forging XP, or forge count. It is input evidence, not the final ten-skill ledger.
Source cursor
The last consumed source-counter value stored in PlayerSkills for one rule. It prevents a later synchronization from awarding the same delta twice.
Backfill
A rule flag that allows the first synchronization to consume an existing source counter, subject to a per-sync delta bound.
Rule revision
A u32 changed whenever thresholds or source rules change. PlayerSkills records the revision used for its cached levels.
Cumulative threshold
The total XP required to reach a level. Level 2 compares against the second total, not an independent second-tier cost.
Mining proof
Validation through the Instructions sysvar that the same transaction contains an approved Chunk mining instruction for the claimed coordinate and owner.
Burden route award
A proof-bound award equal to whole 20 kg steps of pre-mine Backpack mass multiplied by horizontal distance in 16-block chunks, with distance capped at five chunks.
Atomic transaction
All instructions succeed or all state changes roll back. A mine and its skill synchronization cannot partially settle.
Program domain
The ownership and PDA namespace of one Solana program. Identical seed text under Chunk and Game produces different source accounts.
Immediate refresh
A forced Player PDA refresh after mining or Smelting confirmation so the UI does not wait for the normal polling interval.

Key points

One owner has one authoritative skill ledger

PlayerSkills owns all ten XP totals and all ten cached levels. The browser does not derive, merge, or persist an alternative XP balance.

Source programs remain evidence producers

Chunk Progress, Game/Smelting Progress, PlayerProfile, and Backpack expose validated counters. Skill rules consume only new counter deltas.

One rule table defines chain levels and Profile bars

Skills recomputes levels from SkillRuleTable thresholds, and the browser decodes those same thresholds for tier labels and progress.

Mining-only awards require transaction proof

The 160-block Swiftness rule and Backpack-mass-and-route Burden rule run only when a matching Chunk mining instruction is proven in the same transaction.

Revision-aware caching reduces RPC work

The browser caches SkillRuleTable per connection and refetches it only when PlayerSkills reports a different rule revision.

Treasury can tune rules without rewriting player data

Thresholds and conversion rules live in SkillRuleTable; PlayerSkills keeps totals, cursors, and the revision used for levels.

ONE TRANSACTION, SEVERAL VERIFIED WRITES

A confirmed mine updates world, inventory, source counters, and PlayerSkills atomically

For an ordinary mine, the browser resolves the canonical block and authoritative Backpack. It builds one transaction containing the Chunk reward instruction, optional equipment and position writes, and a Skills synchronization instruction. Skills receives Chunk Progress, PlayerProfile, Backpack, and the mined coordinate.

When a coordinate is supplied, Skills reads the Instructions sysvar and proves that an approved Chunk mining instruction for the same owner and coordinate appears earlier in the transaction. Generic source rules independently validate every source account by program owner, PDA seeds, magic, owner, GlobalConfig, metric offset, and width.

Solana atomicity is the safety boundary. If source validation, mining proof, level recomputation, or PlayerSkills allocation fails, the earlier tentative mine and Backpack writes roll back. After confirmation, local world state applies only committed blocks and starts a forced PlayerSkills refresh.

World authority Chunk program

Verifies generated terrain and writes broken-block state.

Inventory authority Unified Game / Backpack

Stores accepted resource volume, density-derived mass, and mine sequence.

Skill authority Skills program

Consumes validated source deltas and writes ten XP totals and levels.

Failure behavior Full transaction rollback

No earlier instruction remains committed when a later instruction fails.

Generated NiceChunk ore terrace where one mined block connects a world ledger, Backpack station, source counter, and central skill archive.
The scene separates verified writes while keeping them in one transaction story. Props do not encode instruction order, XP coefficients, account owners, or confirmation status.

SIX CONFIGURED SOURCE RULES

Validated program counters feed all ten skills through explicit coefficients

The current table has six generic source rules. Three read Chunk Progress: Precision XP, Exploration XP, and explored chunk count. One reads Game/Smelting Progress. Two read PlayerProfile: forging XP divided into 100-XP units and forge count. Each rule names source program, PDA seed layout, magic, owner and GlobalConfig offsets, metric width and offset, maximum delta, divisor, and ten XP rates.

One verified event family can feed several skills. New Chunk Precision units feed Precision Gathering, Exploration, Stamina, Strength, and Appraisal. New Smelting units feed Smelting, Forging, Craftsmanship, Stamina, and Appraisal. These are treasury-configured conversions, not browser heuristics.

The configured coefficients are visible in initialization source and on-chain rule bytes. Clients should decode the table rather than duplicate them as authoritative constants.

Rule 1001 Chunk Precision counter

Rates: Precision 115, Exploration 44, Stamina 9, Strength 18, Appraisal 22.

Rules 1002–1003 Chunk Exploration and explored chunks

Feed Exploration plus smaller Stamina, Strength, and Appraisal awards.

Rule 1004 Game/Smelting XP

Rates: Smelting 250, Forging 38, Craftsmanship 28, Stamina 6, Appraisal 36.

Rules 1005–1006 Player forging XP and forge count

Feed Forging, Craftsmanship, Stamina, Strength, and Appraisal.

Generated NiceChunk workshop with six activity stations feeding one ten-slot stone skill archive.
The six stations represent configured source-rule families, not browser counters. Their physical size does not represent coefficients, deltas, or XP totals.

OWNER PDA AND MONOTONIC CURSORS

Owner scoping and source cursors replace browser storage and maximum merging

PlayerSkills is derived from [player-skills-v1, GlobalConfig, owner] under Skills. Two wallets therefore have different accounts on the same browser, and another device reads the same public ledger for the same wallet.

For each generic rule, PlayerSkills stores a cursor and initialized bit. Synchronization rejects a source counter that moves backward. Otherwise it consumes only current minus previous, clamps that delta to the rule maximum, advances the cursor, and adds the scaled XP difference. When a source remains more than maxDeltaPerSync ahead, repeated synchronizations continue bounded catch-up; a repeat has zero delta only after the cursor equals the source counter.

All configured generic rules enable bounded first-sync backfill. If an older counter exceeds that bound, later syncs continue from the cursor. There is no localStorage XP fallback, ownerless XP key, per-skill maximum, or migration branch in the final testnet design.

PlayerSkills seed [player-skills-v1, GlobalConfig, owner]

Skills program ID is also part of PDA derivation.

Duplicate synchronization applied delta = 0

The cursor already equals the source counter.

Counter regression transaction rejected

An initialized counter cannot be lower than its cursor.

Large first sync bounded catch-up

The cursor advances by at most maxDeltaPerSync and can continue later.

Generated NiceChunk archive scene with one wallet-marked skill ledger and guarded one-way counter tracks.
The one-way tracks represent monotonic cursors and owner-scoped state. They do not depict private keys, exact PDA seeds, or storage bytes.

ONE CUMULATIVE THRESHOLD TABLE

The program and Profile use the same ten cumulative thresholds per skill

SkillRuleTable stores ten strictly increasing u64 thresholds for each of ten skills. During synchronization, Skills compares every PlayerSkills XP total with all ten thresholds, writes level 0 through 10, and records the table revision.

The browser validates table program owner, PDA, length, magic, version, bump, active flag, GlobalConfig, skill count, and every increasing threshold. Profile uses those cumulative totals for current-tier requirement and progress. It does not rebuild a separate authoritative curve from xpBase or xpGrowth.

Smelting reaches levels 1, 2, and 10 at cumulative totals 1,200, 4,738, and 192,519. At level 1, the displayed tier requirement is 4,738 − 1,200 = 3,538 XP.

Level range 0–10

Ten cached level bytes correspond to ten threshold arrays.

Threshold validation positive and strictly increasing

Zero, duplicate, or decreasing entries are rejected.

Progress numerator XP − previous cumulative threshold

Clamped to the current tier requirement.

Rule revision copied into PlayerSkills

Shows which table revision produced cached levels.

Generated NiceChunk training stair with ten cumulative milestones read from one central rule tablet.
The stair illustrates cumulative thresholds. Step spacing is artistic and does not encode production threshold values.

ACTION-SPECIFIC SYNCHRONIZATION

Mining, Smelting, and Forging synchronize PlayerSkills at settlement

Ordinary mining, tree felling, bulk mining, and primary support-collapse mining append createSyncPlayerSkillsInstruction after authoritative writes. Synchronization receives a coordinate for mining proof and source accounts needed by generic and burden rules.

Smelting appends Skills synchronization in the same wallet transaction as executeSmelting. Its source list includes Game/Smelting Progress, PlayerProfile, and Backpack. Verified Forging first reads PlayerSkills as its authoritative level input, then appends Skills synchronization after the forge instruction in the same wallet transaction; PlayerProfile and Backpack sources therefore expose the just-written forging counters to that synchronization.

After confirmation, mining and Smelting force the Player synchronization module to refresh. This removes the visible delay where a transaction succeeded but the skill panel waited for its polling cooldown.

Ordinary mining Chunk write + Skills sync

Includes coordinate proof and pre-mine Backpack mass state.

Bulk mining one sync per committed range

A split range receives its own proof and cursor update.

Smelting execute + Skills sync

Both instructions settle or roll back together.

Verified Forging forge + Skills sync

The synchronization follows the forge write in the same wallet transaction.

Profile update forced refresh after confirmation

Bypasses the ordinary 18-second cooldown.

Generated NiceChunk workshop showing mining, smelting, and forging stations committing to one player skill archive.
The scene represents settlement boundaries. It does not claim identical accounts, compute cost, or XP rates for every action.

PROOF-BOUND MINING RULES

Travel distance and carried mass across chunks add specialized Swiftness and Burden XP

The travel rule records the latest proven mining coordinate. From the second mine onward, it computes three-dimensional squared distance. At least 160 blocks awards one Swiftness XP and increments miningTravelCount; the current coordinate becomes the next reference.

Burden uses Backpack pre-mine total mass, the previous proven mining coordinate, and a monotonic mine sequence. Coordinates are floor-divided into 16-block chunks; horizontal distance is max(|delta chunk X|, |delta chunk Z|), capped at five. Every complete 20,000 grams carried earns one mass point per credited chunk, so 40 kg moved three chunks awards 6 XP. The first proven mine establishes the coordinate baseline and awards zero Burden XP.

Both rules resist replay. Travel needs a matching mining instruction in the transaction. Burden ignores an already consumed sequence, rejects regression, and stores cumulative awarded XP rather than fractional mass work. Skills reads fixed Backpack mass fields instead of scanning 99 records, keeping compute bounded.

Travel threshold dx² + dy² + dz² ≥ 160²

Uses all three axes without a square root.

Travel award +1 Swiftness XP

At most once per proven synchronization coordinate.

Burden mass step floor(pre-mine mass / 20 kg)

An incomplete mass step is not carried into a later action.

Burden award mass points × credited chunk distance

Horizontal Chebyshev distance is capped at five chunks; 40 kg across three chunks yields 6 XP.

Generated NiceChunk landscape where a miner travels between distant worksites while carrying a measured Backpack load.
Distance markers and carried blocks illustrate specialized mining rules. They do not encode exact coordinates, mass, or XP.

VALIDATED READ, REVISION-AWARE CACHE

Profile validates PlayerSkills and refetches rules only when revision changes

fetchPlayerProgress derives Chunk Progress, Game/Smelting Progress, and PlayerSkills, then reads all three in one getMultipleAccountsInfo request while fetching SkillRuleTable. Every present account is validated against expected owner, layout, bump, wallet owner, and GlobalConfig.

SkillRuleTable is cached in a WeakMap keyed by Connection. Repeated refresh reuses it. If PlayerSkills.ruleRevision differs from cached table revision, the client performs exactly one forced read before returning XP, levels, thresholds, and rulesCurrent.

Profile passes chain XP, levels, and thresholds directly into skill state. An uninitialized PlayerSkills renders zeros until valid synchronization creates it. A malformed present account fails closed; it is never replaced with browser-derived XP.

Routine read 3 account batch + cached rules

Source domains remain diagnostic while PlayerSkills is authoritative.

Cache scope one RPC Connection object

A different connection has a different cache entry.

Revision mismatch one forced table refetch

Prevents stale thresholds with newer cached levels.

Invalid account reject

Wrong identity, layout, or threshold order is not tolerated.

Generated NiceChunk observatory where one player ledger and one revisioned rule tablet are checked before Profile updates.
The observatory represents validated RPC reads and revision-aware caching. It does not prove latency, finality, or freshness.

READ THE IMPLEMENTATION

Ten formulas and fourteen literal excerpts trace authoritative XP from source delta to Profile refresh

The equations cover PDA identity, cursor deltas, scaled awards, cumulative thresholds, specialized mining rules, and Smelting output. Every excerpt is a continuous substring of current source.

PlayerSkills PDA

PDA_skills = findProgramAddress([player-skills-v1, GlobalConfig, owner], SkillsProgram)

The address is owner-scoped and program-scoped; browser storage is not part of its identity.

SkillRuleTable PDA

PDA_rules = findProgramAddress([skill-rules-v1, GlobalConfig], SkillsProgram)

Thresholds and conversion rules are shared for the configured game domain.

Available source delta

available = initialized ? current − cursor : (backfill ? current : 0); applied = min(available, maxDeltaPerSync)

This makes synchronization replay-safe and bounds catch-up work.

Scaled XP increment

gain = floor(nextCursor × rate / divisor) − floor(previousCursor × rate / divisor)

Cumulative scaling preserves integer remainders without floating-point state.

Authoritative level

level = max({0} ∪ {L ∈ 1..10 | XP ≥ threshold[L]})

Skills writes this level and Profile displays it.

Current tier progress

required = threshold[level + 1] − threshold[level]; current = XP − threshold[level]

Level zero treats its starting threshold as zero.

Mining travel qualification

dx² + dy² + dz² ≥ 160²

Squared distance exactly matches program comparison.

Burden route distance

distanceChunks = min(5, max(|floor(x₂ / 16) − floor(x₁ / 16)|, |floor(z₂ / 16) − floor(z₁ / 16)|))

Y does not contribute. Euclidean floor division maps negative block coordinates to the correct chunks.

Burden XP

gain = floor(preMineMassGrams / 20,000) × distanceChunks

The first proven mine and a same-chunk mine award zero; incomplete mass below one step is not accumulated.

Smelting skill output

outputMultiplierBps = min(15,000, 10,000 + 500 × SmeltingLevel)

Smelting reads PlayerSkills and applies this to every non-merge output's physical volume and integer stack quantity. Material-merge recipes instead preserve all consumed units and volume.

Production Play loads the modular entry

HTML play/index.html
    <script type="module" src="./main.js"></script>

All browser claims begin at /play/index.html and modules imported by /play/main.js.

  1. Line 1

    Load the current game module relative to /play/.

PlayerSkills and SkillRuleTable use final seeds

JavaScript src/chain/nicechunkChain.js
export function derivePlayerSkillsPda(owner, programId = NICECHUNK_SKILLS_PROGRAM_ID) {
  const normalizedOwner = typeof owner === "string" ? new PublicKey(owner) : owner;
  return PublicKey.findProgramAddressSync(
    [Buffer.from(playerSkillsSeed), deriveGlobalConfigPda().toBuffer(), normalizedOwner.toBuffer()],
    programId,
  );
}

export function deriveSkillRuleTablePda(programId = NICECHUNK_SKILLS_PROGRAM_ID) {
  return PublicKey.findProgramAddressSync(
    [Buffer.from(skillRuleTableSeed), deriveGlobalConfigPda().toBuffer()],
    programId,
  );
}

The browser derives the same Skills PDAs used by the program.

  1. First function

    Bind PlayerSkills to GlobalConfig, owner, and Skills program.

  2. Second function

    Bind SkillRuleTable to GlobalConfig and Skills program.

Profile skill state accepts only chain values

JavaScript play/play-profile-skills.js
export function buildProfileSkillState({
  chainXp = null,
  chainLevels = null,
  chainThresholds = null,
} = {}) {
  const levels = normalizeSkillLevels(chainLevels);
  const xpBySkill = normalizeSkillXp(chainXp);
  const thresholdsBySkill = normalizeSkillThresholds(chainThresholds);
  return {
    levels,
    xpBySkill,
    thresholdsBySkill,
    resolvedLevels: { ...levels },
    source: "chain",
  };
}

Final browser state has no local counter, storage fallback, or maximum merge.

  1. Inputs

    Receive authoritative XP, levels, and thresholds.

  2. Return

    Expose normalized chain-sourced skill state.

Profile tier progress uses cumulative thresholds

JavaScript play/play-profile-skills.js
export function profileSkillExperienceRequirement(skill, level, thresholdsBySkill = null) {
  if (level >= PROFILE_SKILL_MAX_LEVEL) return 0;
  const thresholds = normalizedThresholdsForSkill(skill, thresholdsBySkill);
  if (thresholds) {
    const currentLevel = Math.max(0, Math.min(PROFILE_SKILL_MAX_LEVEL - 1, Math.trunc(level)));
    const previousTotal = currentLevel > 0 ? thresholds[currentLevel - 1] : 0;
    return thresholds[currentLevel] - previousTotal;
  }
  const nextLevel = Math.max(1, Math.min(PROFILE_SKILL_MAX_LEVEL, Math.trunc(level) + 1));
  return Math.round((skill?.xpBase ?? 100) * PROFILE_SKILL_XP_REQUIREMENT_MULTIPLIER * Math.pow(nextLevel, skill?.xpGrowth ?? 1.55));
}

export function profileSkillTotalExperienceForLevel(skill, level, thresholdsBySkill = null) {
  const capped = Math.max(0, Math.min(PROFILE_SKILL_MAX_LEVEL, Math.round(Number(level) || 0)));
  if (capped === 0) return 0;
  const thresholds = normalizedThresholdsForSkill(skill, thresholdsBySkill);
  if (thresholds) return thresholds[capped - 1];
  let total = 0;
  for (let previousLevel = 0; previousLevel < capped; previousLevel += 1) {
    total += profileSkillExperienceRequirement(skill, previousLevel);
  }
  return total;
}

export function profileSkillLevelFromXp(skill, xp, thresholdsBySkill = null) {
  const total = Math.max(0, Math.round(Number(xp) || 0));
  let level = 0;
  for (let nextLevel = 1; nextLevel <= PROFILE_SKILL_MAX_LEVEL; nextLevel += 1) {
    if (total < profileSkillTotalExperienceForLevel(skill, nextLevel, thresholdsBySkill)) break;
    level = nextLevel;
  }
  return level;
}

export function profileSkillExperienceProgress(skill, level, xpBySkill = {}, thresholdsBySkill = null) {
  const minimumTotal = profileSkillTotalExperienceForLevel(skill, level, thresholdsBySkill);
  const rawTotal = Number(xpBySkill?.[skill.id] ?? minimumTotal);
  const total = Number.isFinite(rawTotal) ? Math.max(0, Math.round(rawTotal)) : minimumTotal;
  const required = profileSkillExperienceRequirement(skill, level, thresholdsBySkill);
  if (level >= PROFILE_SKILL_MAX_LEVEL) {
    return {
      total,
      current: 0,
      required: 0,
      ratio: 1,
      label: `Total XP ${formatProfileSkillXp(total)}`,
    };
  }
  const current = Math.max(0, Math.min(required, total - minimumTotal));
  return {
    total,
    current,
    required,
    ratio: required > 0 ? current / required : 1,
    label: `XP ${formatProfileSkillXp(current)}/${formatProfileSkillXp(required)}`,
  };
}

Requirement, level, and progress use decoded table thresholds.

  1. Requirement

    Subtract adjacent cumulative thresholds.

  2. Level

    Select the highest reached total.

  3. Progress

    Subtract the current level start from total XP.

Player progress fetch includes Skills and rules

JavaScript src/chain/nicechunkChain.js
export async function fetchPlayerProgress(ownerAddress, {
  connection: connectionOverride = null,
  context = gameContext,
} = {}) {
  if (!ownerAddress) return null;
  const owner = typeof ownerAddress === "string" ? new PublicKey(ownerAddress) : ownerAddress;
  const globalConfig = deriveGlobalConfigPda();
  const [chunkProgress, chunkBump] = derivePlayerProgressPdaForContext(owner, context);
  const [smeltingProgress, smeltingBump] = deriveSmeltingPlayerProgressPdaForContext(owner, context);
  const [playerSkills, skillsBump] = derivePlayerSkillsPda(owner);
  const conn = connectionOverride ?? getNicechunkConnection();
  const [accounts, initialRuleTable] = await Promise.all([
    conn.getMultipleAccountsInfo([chunkProgress, smeltingProgress, playerSkills], "confirmed"),
    fetchSkillRuleTable({ connection: conn }),
  ]);
  const [chunkAccount, smeltingAccount, skillsAccount] = accounts;
  const chunkDomain = decodePlayerProgressDomain({
    account: chunkAccount,
    expectedProgramId: context.chunkProgramId,
    expectedBump: chunkBump,
    owner,
    globalConfig,
  });
  const smeltingDomain = decodePlayerProgressDomain({
    account: smeltingAccount,
    expectedProgramId: context.smeltingProgramId,
    expectedBump: smeltingBump,
    owner,
    globalConfig,
  });
  const skillsDomain = decodePlayerSkillsDomain({
    account: skillsAccount,
    expectedBump: skillsBump,
    owner,
    globalConfig,
  });
  const skillRuleTable = skillsDomain && initialRuleTable.revision !== skillsDomain.ruleRevision
    ? await fetchSkillRuleTable({ connection: conn, force: true })
    : initialRuleTable;
  const emptySkills = Object.fromEntries(PLAYER_SKILL_IDS.map((skillId) => [skillId, 0]));
  return {
    publicKey: chunkProgress.toBase58(),
    smeltingPublicKey: smeltingProgress.toBase58(),
    owner: owner.toBase58(),
    precisionGatheringXp: chunkDomain?.precisionGatheringXp ?? 0,
    smeltingXp: smeltingDomain?.smeltingXp ?? 0,
    explorationXp: chunkDomain?.explorationXp ?? 0,
    playerSkillsPublicKey: playerSkills.toBase58(),
    playerSkillsInitialized: Boolean(skillsDomain),
    skillXp: skillsDomain?.xp ?? emptySkills,
    skillLevels: skillsDomain?.levels ?? emptySkills,
    skillCursorMask: skillsDomain?.cursorMask ?? 0,
    skillThresholds: skillRuleTable.thresholds,
    skillRuleRevision: skillsDomain?.ruleRevision ?? 0,
    skillRuleTableRevision: skillRuleTable.revision,
    skillRulesCurrent: Boolean(skillsDomain && skillsDomain.ruleRevision === skillRuleTable.revision),
    programId: context.chunkProgramId.toBase58(),
    smeltingProgramId: context.smeltingProgramId.toBase58(),
    skillsProgramId: NICECHUNK_SKILLS_PROGRAM_ID.toBase58(),
  };
}

The aggregate read returns PlayerSkills as the ten-skill authority.

  1. Batch

    Read Chunk, Smelting, and PlayerSkills while loading rules.

  2. Revision

    Force one refetch when revisions differ.

  3. Return

    Expose XP, levels, thresholds, and revision metadata.

Rule decoding validates every threshold

JavaScript src/chain/nicechunkChain.js
function decodeSkillRuleTableDomain({ account, expectedBump, globalConfig }) {
  if (!account?.data?.length) throw new Error("skill-rule-table-uninitialized");
  if (!account.owner?.equals?.(NICECHUNK_SKILLS_PROGRAM_ID)) {
    throw new Error("skill-rule-table-owner-invalid");
  }
  const data = Buffer.from(account.data);
  const valid = data.length === skillRuleTableLength
    && data.subarray(0, 8).toString("utf8") === skillRuleTableMagic
    && data.readUInt16LE(8) === skillRuleTableVersion
    && data.readUInt8(10) === expectedBump
    && data.readUInt8(11) === 1
    && data.readUInt8(skillRuleTableSkillCountOffset) === PLAYER_SKILL_IDS.length
    && data.subarray(44, 76).equals(globalConfig.toBuffer());
  if (!valid) throw new Error("skill-rule-table-domain-invalid");

  const thresholds = {};
  for (let skillIndex = 0; skillIndex < PLAYER_SKILL_IDS.length; skillIndex += 1) {
    const values = [];
    let previous = 0;
    for (let levelIndex = 0; levelIndex < 10; levelIndex += 1) {
      const offset = skillRuleTableThresholdsOffset + (skillIndex * 10 + levelIndex) * 8;
      const value = safeChainInteger(data.readBigUInt64LE(offset), "skill threshold");
      if (value <= previous) throw new Error("skill-rule-table-thresholds-invalid");
      values.push(value);
      previous = value;
    }
    thresholds[PLAYER_SKILL_IDS[skillIndex]] = Object.freeze(values);
  }
  return Object.freeze({
    revision: data.readUInt32LE(skillRuleTableRevisionOffset),
    thresholds: Object.freeze(thresholds),
  });
}

Substituted identity, invalid layout, or non-increasing thresholds are rejected.

  1. Identity

    Validate owner, size, magic, version, bump, state, skill count, and GlobalConfig.

  2. Thresholds

    Decode ten safe increasing values per skill.

Synchronization carries mining proof and sources

JavaScript src/chain/nicechunkChain.js
export function createSyncPlayerSkillsInstruction({
  payer,
  owner,
  sourceAccounts = [],
  miningCoordinate = null,
  programId = NICECHUNK_SKILLS_PROGRAM_ID,
}) {
  const normalizedPayer = typeof payer === "string" ? new PublicKey(payer) : payer;
  const normalizedOwner = typeof owner === "string" ? new PublicKey(owner) : owner;
  const [playerSkills] = derivePlayerSkillsPda(normalizedOwner, programId);
  const [ruleTable] = deriveSkillRuleTablePda(programId);
  const uniqueSources = [...new Map(sourceAccounts.map((source) => {
    const publicKey = typeof source === "string" ? new PublicKey(source) : source;
    return [publicKey.toBase58(), publicKey];
  })).values()];
  const coordinate = miningCoordinate ? normalizeSkillMiningCoordinate(miningCoordinate) : null;
  const data = Buffer.alloc(coordinate ? 13 : 1);
  data.writeUInt8(3, 0);
  if (coordinate) {
    data.writeInt32LE(coordinate.x, 1);
    data.writeInt32LE(coordinate.y, 5);
    data.writeInt32LE(coordinate.z, 9);
  }
  return new TransactionInstruction({
    programId,
    keys: [
      { pubkey: normalizedPayer, isSigner: true, isWritable: true },
      { pubkey: normalizedOwner, isSigner: false, isWritable: false },
      { pubkey: playerSkills, isSigner: false, isWritable: true },
      { pubkey: ruleTable, isSigner: false, isWritable: false },
      { pubkey: deriveGlobalConfigPda(), isSigner: false, isWritable: false },
      { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
      ...(coordinate ? [{ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }] : []),
      ...uniqueSources.map((pubkey) => ({ pubkey, isSigner: false, isWritable: false })),
    ],
    data,
  });
}

The Instructions sysvar appears only when a mining coordinate must be proven.

  1. Payload

    Encode tag or tag plus coordinate.

  2. Accounts

    Pass Skills state, rules, proof sysvar, and deduplicated sources.

Generic rules consume bounded monotonic deltas

Rust programs/nicechunk_skills/src/state.rs
        let backfill = rule.flags & RULE_FLAG_BACKFILL_ON_FIRST_SYNC != 0;
        let available_delta = if initialized {
            current_counter.saturating_sub(previous_cursor)
        } else if backfill {
            current_counter
        } else {
            0
        };
        let applied_delta = available_delta.min(rule.max_delta_per_sync);
        let next_cursor = if !initialized && !backfill {
            current_counter
        } else {
            previous_cursor
                .checked_add(applied_delta)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?
        };

        let next_mask = state.cursor_mask | bit;
        data[PLAYER_SKILLS_CURSOR_MASK_OFFSET..PLAYER_SKILLS_CURSOR_MASK_OFFSET + 4]
            .copy_from_slice(&next_mask.to_le_bytes());
        data[cursor_offset..cursor_offset + 8].copy_from_slice(&next_cursor.to_le_bytes());

        if applied_delta == 0 {
            return Ok(CounterApplyResult {
                changed: !initialized || next_cursor != previous_cursor,
                applied_delta,
            });
        }

        for (skill_index, rate) in rule.xp_per_unit.iter().enumerate() {
            if *rate == 0 {
                continue;
            }
            let previous_scaled = (previous_cursor as u128)
                .checked_mul(*rate as u128)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?
                / rule.unit_divisor as u128;
            let next_scaled = (next_cursor as u128)
                .checked_mul(*rate as u128)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?
                / rule.unit_divisor as u128;
            let gained = next_scaled
                .checked_sub(previous_scaled)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
            let gained =
                u64::try_from(gained).map_err(|_| NicechunkSkillsError::ArithmeticOverflow)?;
            let xp_offset = PLAYER_SKILLS_XP_OFFSET + skill_index * 8;
            let next_xp = read_u64(data, xp_offset)
                .checked_add(gained)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
            data[xp_offset..xp_offset + 8].copy_from_slice(&next_xp.to_le_bytes());
        }
        Ok(CounterApplyResult {
            changed: true,
            applied_delta,
        })
    }

The state function advances one cursor and calculates integer XP from scaled cumulative values.

  1. Delta

    Choose backfill or monotonic difference and clamp it.

  2. XP

    Apply every nonzero rate with the divisor.

  3. Write

    Persist cursor and XP together.

Burden multiplies whole mass steps by proven chunk distance

Rust programs/nicechunk_skills/src/state.rs
    pub fn apply_burden_mining_action(
        data: &mut [u8],
        owner: &Pubkey,
        global_config: &Pubkey,
        rule: BurdenMiningRule,
        pre_mine_mass_grams: u64,
        mine_sequence: u64,
        coordinate: MiningCoordinate,
    ) -> Result<CounterApplyResult, NicechunkSkillsError> {
        let state = Self::validate(data, owner, global_config)?;
        rule.validate()?;
        if !rule.enabled || mine_sequence == 0 {
            return Ok(CounterApplyResult {
                changed: false,
                applied_delta: 0,
            });
        }

        let sequence_bit = 1_u32 << BURDEN_SEQUENCE_CURSOR_INDEX;
        let xp_bit = 1_u32 << BURDEN_XP_CURSOR_INDEX;
        let sequence_initialized = state.cursor_mask & sequence_bit != 0;
        let sequence_offset = PLAYER_SKILLS_CURSORS_OFFSET + BURDEN_SEQUENCE_CURSOR_INDEX * 8;
        let previous_sequence = read_u64(data, sequence_offset);
        if sequence_initialized && mine_sequence < previous_sequence {
            return Err(NicechunkSkillsError::SourceCounterRegressed);
        }
        if sequence_initialized && mine_sequence == previous_sequence {
            return Ok(CounterApplyResult {
                changed: false,
                applied_delta: 0,
            });
        }

        let xp_cursor_offset = PLAYER_SKILLS_CURSORS_OFFSET + BURDEN_XP_CURSOR_INDEX * 8;
        let previous_awarded_xp = if state.cursor_mask & xp_bit != 0 {
            read_u64(data, xp_cursor_offset)
        } else {
            0
        };
        let has_previous =
            data[PLAYER_SKILLS_MINING_FLAGS_OFFSET] & PLAYER_SKILLS_FLAG_HAS_LAST_MINE != 0;
        let distance_chunks = if has_previous {
            burden_chunk_distance(
                MiningCoordinate {
                    x: read_i32(data, PLAYER_SKILLS_LAST_MINE_X_OFFSET),
                    y: read_i32(data, PLAYER_SKILLS_LAST_MINE_Y_OFFSET),
                    z: read_i32(data, PLAYER_SKILLS_LAST_MINE_Z_OFFSET),
                },
                coordinate,
                rule.chunk_size_blocks,
            )
            .min(rule.max_distance_chunks as u64)
        } else {
            0
        };
        let mass_points = pre_mine_mass_grams / rule.mass_step_grams;
        let gained_xp = mass_points
            .checked_mul(distance_chunks)
            .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
        let next_awarded_xp = previous_awarded_xp
            .checked_add(gained_xp)
            .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
        if gained_xp > 0 {
            let xp_offset = PLAYER_SKILLS_XP_OFFSET + rule.skill_index as usize * 8;
            let next_xp = read_u64(data, xp_offset)
                .checked_add(gained_xp)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
            data[xp_offset..xp_offset + 8].copy_from_slice(&next_xp.to_le_bytes());
        }
        let next_mask = state.cursor_mask | xp_bit | sequence_bit;
        data[PLAYER_SKILLS_CURSOR_MASK_OFFSET..PLAYER_SKILLS_CURSOR_MASK_OFFSET + 4]
            .copy_from_slice(&next_mask.to_le_bytes());
        data[xp_cursor_offset..xp_cursor_offset + 8]
            .copy_from_slice(&next_awarded_xp.to_le_bytes());
        data[sequence_offset..sequence_offset + 8].copy_from_slice(&mine_sequence.to_le_bytes());
        Ok(CounterApplyResult {
            changed: true,
            applied_delta: gained_xp,
        })
    }

The program derives chunk distance from the previous proven coordinate, multiplies it by whole mass steps, and prevents duplicate mine sequences.

  1. Replay

    Reject regression and ignore a repeated sequence.

  2. Route

    Measure capped horizontal chunk distance and multiply it by complete mass steps.

  3. Writes

    Store XP, cumulative Burden awards, and the consumed sequence.

Travel uses three-dimensional distance

Rust programs/nicechunk_skills/src/state.rs
    pub fn record_mining_coordinate(
        data: &mut [u8],
        owner: &Pubkey,
        global_config: &Pubkey,
        coordinate: MiningCoordinate,
        rule: MiningTravelRule,
        updated_slot: u64,
    ) -> Result<bool, NicechunkSkillsError> {
        Self::validate(data, owner, global_config)?;
        rule.validate()?;
        let has_previous =
            data[PLAYER_SKILLS_MINING_FLAGS_OFFSET] & PLAYER_SKILLS_FLAG_HAS_LAST_MINE != 0;
        let qualifies = rule.enabled
            && has_previous
            && mining_distance_reaches(
                MiningCoordinate {
                    x: read_i32(data, PLAYER_SKILLS_LAST_MINE_X_OFFSET),
                    y: read_i32(data, PLAYER_SKILLS_LAST_MINE_Y_OFFSET),
                    z: read_i32(data, PLAYER_SKILLS_LAST_MINE_Z_OFFSET),
                },
                coordinate,
                rule.minimum_distance,
            );
        if qualifies {
            let skill_index = rule.skill_index as usize;
            let xp_offset = PLAYER_SKILLS_XP_OFFSET + skill_index * 8;
            let next_xp = read_u64(data, xp_offset)
                .checked_add(rule.xp_award as u64)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
            data[xp_offset..xp_offset + 8].copy_from_slice(&next_xp.to_le_bytes());
            let next_count = read_u64(data, PLAYER_SKILLS_MINING_TRAVEL_COUNT_OFFSET)
                .checked_add(1)
                .ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
            data[PLAYER_SKILLS_MINING_TRAVEL_COUNT_OFFSET
                ..PLAYER_SKILLS_MINING_TRAVEL_COUNT_OFFSET + 8]
                .copy_from_slice(&next_count.to_le_bytes());
        }
        data[PLAYER_SKILLS_LAST_MINE_X_OFFSET..PLAYER_SKILLS_LAST_MINE_X_OFFSET + 4]
            .copy_from_slice(&coordinate.x.to_le_bytes());
        data[PLAYER_SKILLS_LAST_MINE_Y_OFFSET..PLAYER_SKILLS_LAST_MINE_Y_OFFSET + 4]
            .copy_from_slice(&coordinate.y.to_le_bytes());
        data[PLAYER_SKILLS_LAST_MINE_Z_OFFSET..PLAYER_SKILLS_LAST_MINE_Z_OFFSET + 4]
            .copy_from_slice(&coordinate.z.to_le_bytes());
        data[PLAYER_SKILLS_MINING_FLAGS_OFFSET] |= PLAYER_SKILLS_FLAG_HAS_LAST_MINE;
        data[PLAYER_SKILLS_UPDATED_SLOT_OFFSET..PLAYER_SKILLS_UPDATED_SLOT_OFFSET + 8]
            .copy_from_slice(&updated_slot.to_le_bytes());
        Ok(qualifies)
    }

The previous coordinate and count live in PlayerSkills.

  1. Qualification

    Compare current and last proven coordinates.

  2. Award

    Add configured XP and count once.

  3. Update

    Store current coordinate for next mine.

Levels use the current rule revision

Rust programs/nicechunk_skills/src/state.rs
    pub fn recompute_levels(
        data: &mut [u8],
        owner: &Pubkey,
        global_config: &Pubkey,
        rule_table_data: &[u8],
        updated_slot: u64,
    ) -> ProgramResult {
        Self::validate(data, owner, global_config)?;
        let rules = RuleTableState::validate(rule_table_data, global_config)?;
        for skill_index in 0..SKILL_COUNT {
            let xp = read_u64(data, PLAYER_SKILLS_XP_OFFSET + skill_index * 8);
            let mut level = 0_u8;
            let mut previous = 0_u64;
            for level_index in 0..LEVEL_COUNT {
                let threshold =
                    RuleTableState::threshold(rule_table_data, skill_index, level_index)?;
                if threshold == 0 || threshold <= previous {
                    return Err(NicechunkSkillsError::InvalidThresholds.into());
                }
                if xp >= threshold {
                    level = (level_index + 1) as u8;
                }
                previous = threshold;
            }
            data[PLAYER_SKILLS_LEVELS_OFFSET + skill_index] = level;
        }
        data[PLAYER_SKILLS_RULE_REVISION_OFFSET..PLAYER_SKILLS_RULE_REVISION_OFFSET + 4]
            .copy_from_slice(&rules.revision.to_le_bytes());
        data[PLAYER_SKILLS_UPDATED_SLOT_OFFSET..PLAYER_SKILLS_UPDATED_SLOT_OFFSET + 8]
            .copy_from_slice(&updated_slot.to_le_bytes());
        Ok(())
    }
}

Every sync refreshes all ten levels and records the table revision.

  1. Validation

    Validate state and rules against GlobalConfig.

  2. Loop

    Compare XP with ten thresholds.

  3. Revision

    Store the revision used.

Ordinary mining appends Skills sync

JavaScript src/chain/nicechunkChain.js
    tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: miningComputeUnitLimit }));
    if (baselineInstruction) tx.add(baselineInstruction);
    tx.add(createMineBlockWithRewardsInstruction({
      authority: session.keypair.publicKey,
      block,
      owner: provider.publicKey,
      backpack: equippedBackpack.publicKey,
      actionId: miningActionId,
      expectedBlockId: canonicalBlock.blockId,
      context,
    }));
    addEquipmentDurabilityInstructions(tx, {
      authority: session.keypair.publicKey,
      owner: provider.publicKey,
      damage: options.durabilityDamage,
    });
    const playerPositionSaved = maybeAddPlayerPositionUpdateInstruction(tx, {
      provider,
      session,
      position: playerPositionForResourceMine(canonicalBlock, options),
    });
    tx.add(createSyncPlayerSkillsInstruction({
      payer: session.keypair.publicKey,
      owner: provider.publicKey,
      sourceAccounts: [
        chunkProgress,
        derivePlayerProfilePda(provider.publicKey)[0],
        equippedBackpack.publicKey,
      ],
      miningCoordinate: canonicalBlock,
    }));

World, equipment, position, and skill writes share one transaction.

  1. Mine

    Commit canonical block and reward.

  2. Skills

    Pass source PDAs, Backpack, and coordinate.

  3. Send

    Sign after assembling all instructions.

Confirmed mining forces Skills refresh

JavaScript play/play-chain-session.js
        if (action === "mine") {
          void Promise.resolve(refreshPlayerProgress({ force: true, quiet: true })).catch(() => null);
        }

Profile can update without waiting for background polling.

  1. Condition

    Run only after confirmed mining.

  2. Call

    Force Player progress refresh without blocking local confirmation.

Confirmed Smelting refreshes Backpack and Skills

JavaScript play/play-smelting.js
      const refreshResult = await refreshBackpack({
        previousUpdatedSlot: snapshot.updatedSlot,
        force: true,
      });
      if (!refreshResult?.ok) {
        console.warn("[NiceChunk Smelting] Transaction confirmed but PDA refresh is pending.", refreshResult);
      }
      const playerRefreshResult = await refreshPlayerProgress({ force: true, quiet: true });
      if (!playerRefreshResult?.ok) {
        console.warn("[NiceChunk Smelting] Transaction confirmed but PlayerSkills refresh is pending.", playerRefreshResult);
      }

The result is followed by authoritative inventory and skill reads.

  1. Backpack

    Wait for post-transaction inventory.

  2. Skills

    Force PlayerSkills and rule metadata refresh.

IMPLEMENTATION EVIDENCE

Where these claims come from

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

play/index.html

Loads /play/main.js as production game entry.

play/play-profile-skills.js

Builds presentation from chain XP, levels, and thresholds only.

play/play-profile-ui.js

Passes PlayerSkills and SkillRuleTable values into Profile.

play/play-chain-player.js

Exposes Skills revision metadata through player refresh.

play/play-chain-session.js

Refreshes PlayerSkills after confirmed mining.

play/play-smelting.js

Refreshes Backpack and PlayerSkills after confirmed Smelting.

src/chain/nicechunkChain.js

Derives, validates, caches, and submits Skills accounts.

sdk/nicechunk-skills.ts

Defines final Skills layouts, PDAs, instructions, and decoders.

scripts/initialize-skill-rules.ts

Defines thresholds, six source rules, travel, burden, and treasury authority.

programs/nicechunk_skills/src/lib.rs

Validates synchronization accounts, source PDAs, and mining proof.

programs/nicechunk_skills/src/state.rs

Owns cursors, mining awards, thresholds, and levels.

programs/nicechunk_chunk/src/lib.rs

Reads authoritative Precision and Exploration levels.

programs/nicechunk_smelting/src/lib.rs

Reads authoritative Smelting level and applies output.

programs/nicechunk_backpack/src/lib.rs

Reads authoritative Forging level.

tests/nicechunk_skills.ts

Checks Skills SDK account and instruction contract.

play/tests/player-progress-chain.test.mjs

Checks Skills validation, thresholds, revision, and caching.

play/tests/play-skill-movement.test.mjs

Checks chain levels, thresholds, and movement.

play/tests/bulk-mining-chain-state-browser.test.mjs

Checks confirmed bulk mining and immediate Skills refresh.