ROOM ECONOMY · RESOURCE RECOVERY

Screeps Tombstone and Ruin Recovery: Reach the Loot Before It Decays

Verification statusOfficial documentation: Checked August 18, 2026 — Creep.withdraw(), Room.getEventLog(), Tombstone/Ruin Store and decay boundaries · Engine source: screeps/engine 4.3.2 · 80977824199a596d174d392fd0cf8c458c21fcbd · Static code review: Passed — closed hostile Ramparts are explicit CostMatrix blockers and processed withdrawal events are matched by target, Creep, and resource identity

VERIFICATION

Evidence and test status

Official documentation
Checked August 18, 2026 — Creep.withdraw(), Room.getEventLog(), Tombstone/Ruin Store and decay boundaries
Engine source
screeps/engine 4.3.2 · 80977824199a596d174d392fd0cf8c458c21fcbd
Static code review
Passed — closed hostile Ramparts are explicit CostMatrix blockers and processed withdrawal events are matched by target, Creep, and resource identity
Policy
Complete-path optimistic reachability bound; not an ETA guarantee
Screeps Console test
Pending — no real-account Console transcript was collected for this revision
Live multi-tick verification pending
Pending — no live decay race, hostile-Rampart route, or multiplayer withdrawal-contention trace was collected

Why expiry-first ranking can still fail

ticksToDecay is useful urgency data, but urgency is not reachability. A recovery Creep can repeatedly prefer the object with the smallest timer even when terrain, blocking Structures, a closed hostile Rampart, or a long detour makes that target a bad rescue attempt.

Keep three questions separate:

  • API fact: visible Tombstones and Ruins expose a Store and ticksToDecay.
  • Path fact: PathFinder.search() can be incomplete, and a useful salvage search should model stable blocking Structures rather than treating range as reachability.
  • Project policy: do not assign a target when even an optimistic complete-path lower bound leaves too little lifetime for the withdrawal.

The policy below filters obvious doomed assignments. It does not promise that a Creep will move one path tile every tick.

Tombstone, Ruin, and withdraw() boundaries

A dropped Resource uses creep.pickup(resource). A Tombstone or Ruin exposes a Store, so use creep.withdraw(target, resourceType, amount). The target must be adjacent to the Creep.

Do not persist the JavaScript target object across ticks. Save a stable ID, resolve the current object with Game.getObjectById(), and validate it again. A Tombstone or Ruin can decay, another Creep can withdraw from it, and the resource you originally selected can disappear while your Creep is travelling.

An accepted withdraw() request is not a reservation. The current engine processor re-reads the Creep's free Store capacity and the target's current stock while applying the intent. The requested amount can therefore be reduced before settlement when same-tick state has changed.

A hostile non-public Rampart is a separate movement and withdrawal blocker. Do not assume that checking only OBSTACLE_OBJECT_TYPES covers that case: the current movement processor handles closed hostile Ramparts with its own rule.

Use a conservative reachability policy

The example builds a one-room CostMatrix for stable Structure obstacles. Roads receive cost 1. Owned or public Ramparts remain traversable. A hostile non-public Rampart is explicitly set to 255, and other obstacle Structure types use OBSTACLE_OBJECT_TYPES.

A complete path gives an optimistic tile-count lower bound to withdrawal range 1. An incomplete search is rejected. Then compare that lower bound with ticksToDecay:

optimistic travel ticks = completePath.length
minimum safe lifetime = optimistic travel ticks + 1 withdrawal tick + margin

The extra withdrawal tick avoids treating “reach range 1 at the last possible moment” as safe. The margin is a local engineering choice. Fatigue, swamps, traffic, moving Creeps, pulls, hostile movement, and a different production movement policy can all make real travel slower, so this is a lower-bound filter, not an ETA guarantee.

For clarity, the teaching implementation re-runs the bound when it revalidates a saved target. That costs CPU. A production recovery system can cache assignment data and invalidate it on structural changes, movement failures, or a policy-specific refresh interval.

Complete recovery example

This example scans visible Tombstones and Ruins in one room, rejects protected or unreachable candidates, refuses targets that cannot survive the optimistic arrival bound, stores only stable identity across ticks, recomputes the requested amount immediately before withdraw(), and verifies the processed result on a later tick.

const RESOURCE_PRIORITY = [
  RESOURCE_POWER,
  RESOURCE_OPS,
  RESOURCE_GHODIUM,
  RESOURCE_CATALYST,
  RESOURCE_ZYNTHIUM,
  RESOURCE_UTRIUM,
  RESOURCE_LEMERGIUM,
  RESOURCE_KEANIUM,
  RESOURCE_OXYGEN,
  RESOURCE_HYDROGEN,
  RESOURCE_ENERGY
];

const RECOVERY_MARGIN = 2;
const HISTORY_LIMIT = 20;

function resourceRank(resourceType) {
  const index = RESOURCE_PRIORITY.indexOf(resourceType);
  return index === -1 ? RESOURCE_PRIORITY.length : index;
}

function isClosedHostileRampart(structure) {
  return structure.structureType === STRUCTURE_RAMPART
    && structure.my !== true
    && structure.isPublic !== true;
}

function isProtectedTarget(target) {
  return target.pos.lookFor(LOOK_STRUCTURES).some(isClosedHostileRampart);
}

function buildRecoveryMatrix(room) {
  const costs = new PathFinder.CostMatrix();

  for (const structure of room.find(FIND_STRUCTURES)) {
    if (structure.structureType === STRUCTURE_ROAD) {
      costs.set(structure.pos.x, structure.pos.y, 1);
      continue;
    }

    if (structure.structureType === STRUCTURE_RAMPART) {
      if (structure.my === true || structure.isPublic === true) {
        continue;
      }

      costs.set(structure.pos.x, structure.pos.y, 255);
      continue;
    }

    if (OBSTACLE_OBJECT_TYPES.includes(structure.structureType)) {
      costs.set(structure.pos.x, structure.pos.y, 255);
    }
  }

  return costs;
}

function chooseResourceType(target) {
  return Object.keys(target.store)
    .filter(type => target.store.getUsedCapacity(type) > 0)
    .sort((left, right) =>
      resourceRank(left) - resourceRank(right)
      || target.store.getUsedCapacity(right)
        - target.store.getUsedCapacity(left)
      || left.localeCompare(right)
    )[0] ?? null;
}

function searchToWithdrawRange(creep, target, matrix) {
  const search = PathFinder.search(
    creep.pos,
    { pos: target.pos, range: 1 },
    {
      maxRooms: 1,
      roomCallback(roomName) {
        return roomName === creep.room.name ? matrix : false;
      }
    }
  );

  return search.incomplete ? null : search.path;
}

function describeCandidate(creep, target, matrix) {
  const free = creep.store.getFreeCapacity();
  if (
    free <= 0
    || !target?.id
    || !target.store
    || !Number.isFinite(target.ticksToDecay)
    || target.ticksToDecay <= 0
    || isProtectedTarget(target)
  ) return null;

  const resourceType = chooseResourceType(target);
  if (!resourceType) return null;

  const available = target.store.getUsedCapacity(resourceType);
  const requestedAmount = Math.min(available, free);
  if (!Number.isFinite(requestedAmount) || requestedAmount <= 0) {
    return null;
  }

  const path = creep.pos.isNearTo(target)
    ? []
    : searchToWithdrawRange(creep, target, matrix);
  if (path === null) return null;

  const optimisticTravelTicks = path.length;
  const minimumSafeLifetime = optimisticTravelTicks + 1 + RECOVERY_MARGIN;
  if (target.ticksToDecay < minimumSafeLifetime) return null;

  return {
    target,
    targetId: target.id,
    resourceType,
    requestedAmount,
    ticksToDecay: target.ticksToDecay,
    optimisticTravelTicks,
    slack: target.ticksToDecay - minimumSafeLifetime,
    rank: resourceRank(resourceType)
  };
}

function selectCandidate(creep) {
  const matrix = buildRecoveryMatrix(creep.room);

  return [
    ...creep.room.find(FIND_TOMBSTONES),
    ...creep.room.find(FIND_RUINS)
  ]
    .map(target => describeCandidate(creep, target, matrix))
    .filter(Boolean)
    .sort((left, right) =>
      left.slack - right.slack
      || left.rank - right.rank
      || right.requestedAmount - left.requestedAmount
      || left.optimisticTravelTicks - right.optimisticTravelTicks
      || left.targetId.localeCompare(right.targetId)
    )[0] ?? null;
}

function recoveryMemory() {
  Memory.recovery ??= { pending: {}, history: [] };
  return Memory.recovery;
}

function verifyPrevious(creep) {
  const memory = recoveryMemory();
  const pending = memory.pending[creep.name];
  if (!pending || pending.tick >= Game.time) return null;

  const target = Game.getObjectById(pending.targetId);
  const creepNow = creep.store.getUsedCapacity(pending.resourceType);
  const targetNow = target?.store
    ? target.store.getUsedCapacity(pending.resourceType)
    : null;

  // Room.getEventLog() returns events from the previous tick.
  const exactEvent = creep.room.getEventLog().find(event =>
    event.event === EVENT_TRANSFER
    && event.objectId === pending.targetId
    && event.data?.targetId === pending.creepId
    && event.data?.resourceType === pending.resourceType
  );

  const creepGain = creepNow - pending.creepBefore;
  const targetLoss = targetNow === null
    ? null
    : pending.targetBefore - targetNow;
  const processedAmount = exactEvent?.data?.amount ?? null;

  const status = Number.isFinite(processedAmount) && processedAmount > 0
    ? 'exact-transfer-event-observed'
    : creepGain > 0 && targetLoss !== null && targetLoss > 0
      ? 'matching-store-deltas-observed'
      : creepGain > 0
        ? 'creep-gain-observed'
        : target === null
          ? 'target-unavailable-after-submit'
          : 'withdraw-not-observed';

  const record = {
    verifiedAt: Game.time,
    ...pending,
    creepNow,
    targetNow,
    creepGain,
    targetLoss,
    processedAmount,
    status
  };

  memory.history.push(record);
  memory.history = memory.history.slice(-HISTORY_LIMIT);
  delete memory.pending[creep.name];
  return record;
}

function runRecoveryCreep(creep) {
  const verification = verifyPrevious(creep);

  if (creep.spawning) return { status: 'creep-spawning', verification };
  if (creep.getActiveBodyparts(CARRY) <= 0) {
    return { status: 'no-active-carry-part', verification };
  }
  if (creep.store.getFreeCapacity() <= 0) {
    return { status: 'creep-full', verification };
  }

  let candidate = null;
  if (creep.memory.recoveryTargetId) {
    const saved = Game.getObjectById(creep.memory.recoveryTargetId);
    if (saved) {
      const matrix = buildRecoveryMatrix(creep.room);
      candidate = describeCandidate(creep, saved, matrix);
    }
  }

  if (!candidate) {
    candidate = selectCandidate(creep);
    creep.memory.recoveryTargetId = candidate?.targetId ?? null;
  }

  if (!candidate) {
    return { status: 'no-recoverable-target', verification };
  }

  if (!creep.pos.isNearTo(candidate.target)) {
    const moveResult = creep.moveTo(candidate.target, {
      range: 1,
      reusePath: 3
    });

    if (moveResult !== OK) creep.memory.recoveryTargetId = null;

    return {
      status: 'moving-to-recovery-target',
      targetId: candidate.targetId,
      moveResult,
      optimisticTravelTicks: candidate.optimisticTravelTicks,
      slack: candidate.slack,
      verification
    };
  }

  const creepBefore = creep.store.getUsedCapacity(candidate.resourceType);
  const targetBefore = candidate.target.store.getUsedCapacity(
    candidate.resourceType
  );
  const requestedAmount = Math.min(
    targetBefore,
    creep.store.getFreeCapacity()
  );

  if (requestedAmount <= 0) {
    creep.memory.recoveryTargetId = null;
    return { status: 'target-changed-before-withdraw', verification };
  }

  const result = creep.withdraw(
    candidate.target,
    candidate.resourceType,
    requestedAmount
  );

  if (result === OK) {
    recoveryMemory().pending[creep.name] = {
      tick: Game.time,
      creepId: creep.id,
      targetId: candidate.targetId,
      resourceType: candidate.resourceType,
      requestedAmount,
      creepBefore,
      targetBefore
    };
  } else {
    creep.memory.recoveryTargetId = null;
  }

  return {
    status: result === OK ? 'withdraw-submitted' : 'withdraw-failed',
    result,
    targetId: candidate.targetId,
    resourceType: candidate.resourceType,
    requestedAmount,
    verification
  };
}

module.exports.loop = function () {
  const creep = Game.creeps.Recovery1;
  if (!creep) return;

  const outcome = runRecoveryCreep(creep);
  if (outcome.status === 'withdraw-failed') {
    console.log(JSON.stringify({
      type: 'resource-recovery-problem',
      tick: Game.time,
      creepName: creep.name,
      ...outcome
    }));
  }
};

Verify the processed result later

withdraw() returning OK means the operation was scheduled successfully. It does not prove that a same-line Store read contains the processed result.

Room.getEventLog() returns events from the previous tick. In the checked engine, a processed withdrawal emits EVENT_TRANSFER with the Tombstone/Ruin ID as objectId, the receiving Creep ID as targetId, the resource type, and the processed amount. Matching all of those fields on the next tick is stronger evidence than inferring the withdrawal only from aggregate Store deltas.

The example therefore records requestedAmount separately from processedAmount. Store deltas remain fallback evidence because other same-tick activity can change either Store.

withdraw() return-code checklist

CodeMeaning in this workflowResponse
OKThe operation was scheduled successfully.Verify the exact previous-tick event on the next tick.
ERR_NOT_OWNERThe Creep is not yours, or a hostile non-public Rampart covers the target.Stop the assignment; do not treat the target as withdrawable.
ERR_BUSYThe Creep is still spawning.Wait.
ERR_NOT_ENOUGH_RESOURCESThe target does not have the requested resource amount.Re-read the target Store and recompute the request.
ERR_INVALID_TARGETThe target is gone or is not a valid withdraw target.Clear the saved ID and reselect.
ERR_FULLThe Creep cannot receive more resources.Deliver before recovering more.
ERR_NOT_IN_RANGEThe target is outside adjacent range.Keep movement and withdrawal diagnostics separate.
ERR_INVALID_ARGSThe resource type or amount is invalid.Recompute from the current Stores.

Evidence and policy boundaries

The current official Screeps documentation and screeps/engine 4.3.2 source were rechecked on August 18, 2026. The engine master is commit 80977824199a596d174d392fd0cf8c458c21fcbd. The article deliberately separates documented API behavior, checked processor behavior, and local salvage policy.

Policy boundary: path.length + 1 + margin is only an optimistic lower-bound filter. The matrix models stable Structure obstacles, including closed hostile Ramparts, but it does not convert terrain or fatigue into exact travel ticks, reserve traffic lanes, predict moving Creeps, or reproduce every custom movement system.

Live evidence: Screeps Console test: Pending. Live decay-race test: Pending. Multiplayer contention test: Pending. No live result is fabricated here.

Continue with pickup() for dropped resources, withdraw() from Containers, or the cross-tick object ID guide.

SOURCE AND SCOPE

Review the source, evidence, or next system

This English guide is rewritten for a focused search intent while preserving the technical scope and verification boundaries of its Chinese source. Live-room evidence is claimed only when the verification record says it exists.