SPAWN LIFECYCLE · PRESPAWN REPLACEMENT

Screeps Prespawn Replacement: Start the Next Creep Before the Current One Dies

Verification statusDocumentation: Official Creep, Spawn, constants, and game-loop references checked · Syntax: Complete Chinese room manager checked offline · Offline cases: 20 passed

VERIFICATION

Evidence and test status

Documentation
Official Creep, Spawn, constants, and game-loop references checked
Syntax
Complete Chinese room manager checked offline
Offline cases
20 passed
Live shard
Pending

Calculate the replacement deadline

Waiting until a Harvester, Hauler, or Upgrader disappears before calling spawnCreep() starts the replacement too late. A successor may wait for current Spawn work, consume time for every body part, and travel to its work position while the old Creep's ticksToLive keeps falling.

replacement lead = earliest Spawn wait
                   + body spawn time
                   + travel time
                   + safety buffer

The base body time is body.length × CREEP_SPAWN_TIME. This guide keeps the documented base time as a conservative default instead of assuming PWR_OPERATE_SPAWN will remain available.

Travel is not linear range. Roads, swamps, MOVE-to-load ratio, traffic, exits, and a blocked work tile can all delay arrival. Begin with a conservative role value, then record the tick when spawning finishes and the tick when the Creep first satisfies its role-specific arrival condition.

Keep the search intent separate

The spawnCreep return-code guide diagnoses one failed request. The dynamic body guide decides which body is affordable. The emergency recovery guide starts after a critical role has already disappeared.

This route owns one narrower intent: begin normal replacement before the old Creep leaves its work position. Restoring the original prespawn slug avoids creating a second competing Spawn-queue page.

Prevent duplicate replacements

A role target of two can temporarily become three after the successor finishes while the low-TTL Creep remains alive. Treat that surplus as coverage:

uncovered expiring count
= active Creeps inside the lead threshold
- temporary surplus above the role target

Submit another request only when the uncovered value is positive. Count currently spawning Creeps separately so one pending successor is not requested again.

Use a testable decision function

function evaluateRoleReplacement(input) {
  const {
    targetCount,
    activeTtls,
    spawningCount,
    spawnTicks,
    spawnWaitTicks,
    travelTicks,
    safetyBuffer
  } = input;

  const integers = [
    targetCount,
    spawningCount,
    spawnTicks,
    spawnWaitTicks,
    travelTicks,
    safetyBuffer
  ];

  if (
    !integers.every(Number.isInteger)
    || targetCount < 1
    || spawningCount < 0
    || spawnTicks < 1
    || spawnWaitTicks < 0
    || travelTicks < 0
    || safetyBuffer < 0
    || !Array.isArray(activeTtls)
    || !activeTtls.every(ttl =>
      Number.isInteger(ttl) && ttl >= 0
    )
  ) {
    return { valid: false, shouldSpawn: false, reason: 'invalid-input' };
  }

  const leadTicks = spawnWaitTicks + spawnTicks + travelTicks + safetyBuffer;
  const totalCount = activeTtls.length + spawningCount;
  const missingCount = Math.max(0, targetCount - totalCount);
  const surplusCount = Math.max(0, totalCount - targetCount);
  const dueCount = activeTtls.filter(ttl => ttl <= leadTicks).length;
  const uncoveredDueCount = Math.max(0, dueCount - surplusCount);
  const minimumSlack = activeTtls.length > 0
    ? Math.min(...activeTtls.map(ttl => ttl - leadTicks))
    : Number.NEGATIVE_INFINITY;

  if (missingCount > 0) {
    return {
      valid: true,
      shouldSpawn: true,
      reason: 'count-below-target',
      leadTicks,
      missingCount,
      uncoveredDueCount,
      minimumSlack
    };
  }

  if (uncoveredDueCount > 0) {
    return {
      valid: true,
      shouldSpawn: true,
      reason: 'prespawn-due',
      leadTicks,
      missingCount,
      uncoveredDueCount,
      minimumSlack
    };
  }

  return {
    valid: true,
    shouldSpawn: false,
    reason: 'covered',
    leadTicks,
    missingCount,
    uncoveredDueCount,
    minimumSlack
  };
}

minimumSlack is positive before the deadline, zero at the threshold, and negative after the ideal submission time. It is a local scheduling metric, not a Screeps return code.

Submit through one room manager

All role requests should converge on one room-level owner. The following bounded manager prevents repeated execution in the same tick, ranks actual shortages before normal replacements, performs dryRun, and preserves the formal result.

const HISTORY_LIMIT = 20;

const ROLE_CONFIG = {
  harvester: {
    priority: 10,
    targetCount: 2,
    body: [WORK, WORK, CARRY, MOVE],
    travelTicks: 25,
    safetyBuffer: 15
  },
  hauler: {
    priority: 20,
    targetCount: 2,
    body: [CARRY, CARRY, MOVE],
    travelTicks: 18,
    safetyBuffer: 15
  },
  upgrader: {
    priority: 30,
    targetCount: 1,
    body: [WORK, WORK, CARRY, CARRY, MOVE, MOVE],
    travelTicks: 12,
    safetyBuffer: 15
  }
};

function getRoomState(roomName) {
  Memory.prespawnReplacement ??= {};
  Memory.prespawnReplacement[roomName] ??= {
    lastRunTick: null,
    pending: null,
    history: []
  };
  return Memory.prespawnReplacement[roomName];
}

function verifyPendingReplacement(state) {
  const pending = state.pending;
  if (!pending || pending.tick >= Game.time) return null;

  const creep = Game.creeps[pending.name] ?? null;
  const spawn = Game.spawns[pending.spawnName] ?? null;
  const observedInSpawn = spawn?.spawning?.name === pending.name;
  const observedAsCreep = Boolean(creep);
  const record = {
    ...pending,
    verifiedAt: Game.time,
    observedInSpawn,
    observedAsCreep,
    status: observedInSpawn || observedAsCreep
      ? 'replacement-observed'
      : 'replacement-not-observed'
  };

  state.history ??= [];
  state.history.push(record);
  state.history = state.history.slice(-HISTORY_LIMIT);
  state.lastVerification = record;
  state.pending = null;
  return record;
}

function getUsableSpawns(room) {
  return room.find(FIND_MY_SPAWNS)
    .filter(spawn => spawn.my === true && spawn.isActive())
    .sort((left, right) => left.name.localeCompare(right.name));
}

function belongsToRoom(creep, roomName) {
  const homeRoom = creep.memory?.homeRoom ?? creep.memory?.home;
  return typeof homeRoom === 'string'
    ? homeRoom === roomName
    : creep.room.name === roomName;
}

function createRoleRequest(room, role, config, spawnWaitTicks) {
  const creeps = Object.values(Game.creeps).filter(creep =>
    creep.memory?.role === role && belongsToRoom(creep, room.name)
  );
  const activeCreeps = creeps
    .filter(creep => !creep.spawning && Number.isInteger(creep.ticksToLive))
    .sort((left, right) =>
      left.ticksToLive - right.ticksToLive
      || left.name.localeCompare(right.name)
    );
  const spawningCount = creeps.filter(creep => creep.spawning).length;
  const decision = evaluateRoleReplacement({
    targetCount: config.targetCount,
    activeTtls: activeCreeps.map(creep => creep.ticksToLive),
    spawningCount,
    spawnTicks: config.body.length * CREEP_SPAWN_TIME,
    spawnWaitTicks,
    travelTicks: config.travelTicks,
    safetyBuffer: config.safetyBuffer
  });

  if (!decision.valid || !decision.shouldSpawn) return null;
  return {
    role,
    config,
    replacementFor: activeCreeps[0]?.name ?? null,
    ...decision
  };
}

function compareRequests(left, right) {
  const leftTier = left.reason === 'count-below-target' ? 0 : 1;
  const rightTier = right.reason === 'count-below-target' ? 0 : 1;
  if (leftTier !== rightTier) return leftTier - rightTier;
  if (leftTier === 0) {
    return right.missingCount - left.missingCount
      || left.config.priority - right.config.priority
      || left.role.localeCompare(right.role);
  }
  return left.minimumSlack - right.minimumSlack
    || left.config.priority - right.config.priority
    || left.role.localeCompare(right.role);
}

function runRoomPrespawnManager(room) {
  const state = getRoomState(room.name);
  const verification = verifyPendingReplacement(state);

  if (state.lastRunTick === Game.time) {
    return { status: 'already-ran-this-tick', verification };
  }
  state.lastRunTick = Game.time;

  const spawns = getUsableSpawns(room);
  if (spawns.length === 0) return { status: 'no-usable-spawn', verification };

  const spawnWaitTicks = Math.min(
    ...spawns.map(spawn => spawn.spawning?.remainingTime ?? 0)
  );
  const requests = Object.entries(ROLE_CONFIG)
    .map(([role, config]) =>
      createRoleRequest(room, role, config, spawnWaitTicks)
    )
    .filter(Boolean)
    .sort(compareRequests);

  if (requests.length === 0) return { status: 'no-request', verification };

  const request = requests[0];
  const spawn = spawns.find(item => !item.spawning);
  if (!spawn) return { status: 'all-spawns-busy', request, verification };

  const name = [request.role, room.name, spawn.name, Game.time].join('-');
  const memory = {
    role: request.role,
    homeRoom: room.name,
    replacementReason: request.reason,
    replacementFor: request.replacementFor
  };
  const dryRunResult = spawn.spawnCreep(request.config.body, name, {
    memory,
    dryRun: true
  });
  if (dryRunResult !== OK) {
    return { status: 'dry-run-failed', dryRunResult, request, verification };
  }

  const result = spawn.spawnCreep(request.config.body, name, { memory });
  if (result === OK) {
    state.pending = {
      tick: Game.time,
      name,
      spawnName: spawn.name,
      role: request.role,
      reason: request.reason,
      replacementFor: request.replacementFor
    };
  }

  return {
    status: result === OK ? 'spawn-submitted' : 'spawn-failed',
    result,
    dryRunResult,
    name,
    spawnName: spawn.name,
    request,
    verification
  };
}

Run emergency recovery before this manager when a critical role is already absent. Run ordinary expansion requests only when neither emergency recovery nor prespawn replacement has used the room's final Spawn submission.

Preserve and verify Spawn results

ResultMeaning for the queue
OKSave the accepted name and observe it later
ERR_BUSYFind another module that bypassed the manager
ERR_NOT_ENOUGH_ENERGYUse a smaller body or emergency policy where appropriate
ERR_NAME_EXISTSFix naming or duplicate same-tick ownership
ERR_INVALID_ARGSStop blind retries and repair configuration
ERR_RCL_NOT_ENOUGHCheck RCL and structure activity

replacement-observed only proves that the accepted name appeared in Game.creeps or spawn.spawning. It does not prove the new unit reached work before the old unit left.

Use the planner as an estimate

The Spawn Queue and Replacement Planner estimates body production time, normal versus CLAIM lifetime, average Spawn utilization, travel allowance, safety margin, and optional OPERATE_SPAWN planning.

It is not an exact runtime scheduler. It does not simulate simultaneous expirations, Energy starvation, blocked spawn directions, DISRUPT_SPAWN, competing modules, or the formal spawnCreep() result.

Evidence boundaries

Twenty offline cases passed, including healthy TTLs, threshold equality, shortages, no active Creep, spawning coverage, completed temporary surplus, multiple expirations, Spawn waiting, zero travel and buffer, invalid TTL and configuration inputs, and large finite values. The complete Chinese manager passed a JavaScript syntax check.

Live Console, official-shard Spawn competition, work-position arrival, PWR_OPERATE_SPAWN changes, hostile disruption, and true zero-downtime handoffs remain pending. A real no-gap claim requires the replacement's first work-position tick to occur before the old Creep's last work tick.

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.