SPAWN SYSTEM · ENERGY DIAGNOSTICS

Why room.energyAvailable Stays Below Capacity in Screeps

Verification statusOfficial documentation: Checked August 18, 2026 — Room.energyAvailable, Room.energyCapacityAvailable, Structure.isActive(), Creep.transfer(), and Room.getEventLog() · Engine source: screeps/engine 4.3.2 · 80977824199a596d174d392fd0cf8c458c21fcbd · Static code review: Passed — active structure reconciliation, stable target identity, path-aware target selection, exact transfer-event matching, and requested-versus-processed amount separation

VERIFICATION

Evidence and test status

Official documentation
Checked August 18, 2026 — Room.energyAvailable, Room.energyCapacityAvailable, Structure.isActive(), Creep.transfer(), and Room.getEventLog()
Engine source
screeps/engine 4.3.2 · 80977824199a596d174d392fd0cf8c458c21fcbd
Static code review
Passed — active structure reconciliation, stable target identity, path-aware target selection, exact transfer-event matching, and requested-versus-processed amount separation
Evidence model
Exact EVENT_TRANSFER first; matching Store deltas second; room aggregate only as concurrent-demand context
Screeps Console test
Pending — no real-account Console transcript was collected for this revision
Live multi-tick verification pending
Pending — no live multi-hauler contention or transfer-plus-spawn-consumption trace was collected

What the two Room values actually measure

room.energyAvailable is the current Energy available in your room's Spawn-and-Extension network. room.energyCapacityAvailable is the corresponding total capacity. Energy in Storage, Containers, Terminals, Links, Towers, Labs, or a Creep's Store does not directly increase either Room aggregate.

The official Room API describes these values in terms of Spawns and Extensions. The current checked engine goes one step deeper: while creating the runtime Room snapshot, it adds owned Spawn/Extension Energy and capacity only when the underlying object is not marked off. That is why an action-oriented diagnostic should inspect owned active Spawns and Extensions instead of blindly summing every visible structure.

This is a current-engine implementation boundary, not a promise that player code should depend on a private off field. Use the public structure.isActive() check in your script.

Separate production demand from delivery failure

A Room aggregate below capacity does not automatically mean the filler is broken. A valid delivery and a valid Spawn consumption can happen around the same period. The target Extension can gain Energy while room.energyAvailable stays flat or even falls because another Spawn/Extension simultaneously funds production.

That creates an important evidence hierarchy:

  1. Strongest for one submitted fill: the exact processed EVENT_TRANSFER for the sending Creep, target structure, resource type, and amount.
  2. Useful fallback: matching target gain and Creep loss on the next snapshot.
  3. Context only: the room-level Energy delta, because other Spawn-network activity can change it.

Do not require room.energyAvailable > previousValue as proof that one transfer() worked.

Reconcile the exact Spawn and Extension Stores

Start with a read-only same-tick snapshot. It tells you which active owned structure has free capacity and whether the structure-level totals agree with the Room aggregate you are trying to diagnose.

function isSpawnEnergyStructure(structure) {
  return structure.structureType === STRUCTURE_SPAWN
    || structure.structureType === STRUCTURE_EXTENSION;
}

function getActiveSpawnEnergyStructures(room) {
  return room.find(FIND_MY_STRUCTURES, {
    filter: structure =>
      isSpawnEnergyStructure(structure)
      && structure.isActive()
      && structure.store.getCapacity(RESOURCE_ENERGY) > 0
  });
}

function describeRoomEnergy(room) {
  const structures = getActiveSpawnEnergyStructures(room)
    .map(structure => ({
      id: structure.id,
      type: structure.structureType,
      used: structure.store.getUsedCapacity(RESOURCE_ENERGY),
      capacity: structure.store.getCapacity(RESOURCE_ENERGY),
      free: structure.store.getFreeCapacity(RESOURCE_ENERGY)
    }))
    .sort((left, right) =>
      left.type.localeCompare(right.type)
      || left.id.localeCompare(right.id)
    );

  const measuredUsed = structures.reduce(
    (sum, item) => sum + item.used,
    0
  );
  const measuredCapacity = structures.reduce(
    (sum, item) => sum + item.capacity,
    0
  );

  return {
    tick: Game.time,
    roomName: room.name,
    roomEnergyAvailable: room.energyAvailable,
    roomEnergyCapacityAvailable: room.energyCapacityAvailable,
    missingEnergy: Math.max(
      0,
      room.energyCapacityAvailable - room.energyAvailable
    ),
    measuredUsed,
    measuredCapacity,
    usedDifference: room.energyAvailable - measuredUsed,
    capacityDifference:
      room.energyCapacityAvailable - measuredCapacity,
    structures
  };
}

On the checked engine, an unexplained difference is worth preserving as evidence, but do not jump straight to “engine bug.” First record the full snapshot, Controller level/ownership, every relevant structure's isActive() result, and the exact tick. A stale assumption about which Extensions are active is a much more ordinary diagnosis.

Keep one fill target stable while moving

A common filler anti-pattern is to sort all empty Extensions every tick and immediately chase whichever one looks closest now. When several haulers are filling the same network, that can cause target churn: a Creep walks toward Extension A, another hauler changes the free-capacity ordering, and the first Creep turns toward B before it ever delivers.

Use a stable target ID while the target remains valid. Re-resolve the object each tick, require the same room, ownership, active state, supported structure type, and positive Energy capacity, then clear the ID when it becomes full or invalid.

function resolveFillTarget(creep, targetId) {
  if (!targetId) return null;

  const target = Game.getObjectById(targetId);
  if (
    !target
    || target.room?.name !== creep.room.name
    || target.my !== true
    || !isSpawnEnergyStructure(target)
    || !target.isActive()
    || target.store.getFreeCapacity(RESOURCE_ENERGY) <= 0
  ) return null;

  return target;
}

function selectFillTarget(creep) {
  const candidates = getActiveSpawnEnergyStructures(creep.room)
    .filter(target =>
      target.store.getFreeCapacity(RESOURCE_ENERGY) > 0
    )
    .sort((left, right) => left.id.localeCompare(right.id));

  return creep.pos.findClosestByPath(candidates) ?? null;
}

findClosestByPath() is a project choice here because a straight range ranking can repeatedly prefer a structure that the current pathfinder cannot reach. A production traffic system may use a shared CostMatrix or reservation layer instead; the article does not claim this helper is a universal scheduler.

Complete filler with exact next-tick evidence

The complete example accepts one Creep that already carries Energy. It does not choose a withdrawal source. It keeps one target stable while moving, records movement separately, recomputes the transferable amount at adjacent range, submits one transfer(), and verifies the previous tick on the next run.

const HISTORY_LIMIT = 20;

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

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

  const target = Game.getObjectById(pending.targetId);
  const room = Game.rooms[pending.roomName];
  const targetNow = target?.store
    ? target.store.getUsedCapacity(RESOURCE_ENERGY)
    : null;
  const creepNow = creep.store.getUsedCapacity(RESOURCE_ENERGY);
  const roomNow = room ? room.energyAvailable : null;

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

  const processedAmount = exactEvent?.data?.amount ?? null;
  const targetGain = targetNow === null
    ? null
    : targetNow - pending.targetBefore;
  const creepLoss = pending.creepBefore - creepNow;
  const roomDelta = roomNow === null
    ? null
    : roomNow - pending.roomBefore;

  let status = "transfer-not-observed";
  if (Number.isFinite(processedAmount) && processedAmount > 0) {
    status = "exact-transfer-event-observed";
  } else if (targetGain !== null && targetGain > 0 && creepLoss > 0) {
    status = "matching-target-and-creep-delta";
  } else if (targetGain !== null && targetGain > 0) {
    status = "target-gain-observed";
  } else if (creepLoss > 0) {
    status = "creep-loss-observed";
  } else if (target === null) {
    status = "target-unavailable-after-submit";
  }

  const record = {
    verifiedAt: Game.time,
    ...pending,
    processedAmount,
    targetNow,
    creepNow,
    roomNow,
    targetGain,
    creepLoss,
    roomDelta,
    status
  };

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

function runSpawnEnergyFiller(creep) {
  const verification = verifyPreviousEnergyFill(creep);

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

  const carried = creep.store.getUsedCapacity(RESOURCE_ENERGY);
  if (carried <= 0) {
    return { status: "no-carried-energy", verification };
  }

  const roomState = describeRoomEnergy(creep.room);
  if (roomState.missingEnergy <= 0) {
    creep.memory.energyFillTargetId = null;
    return { status: "room-energy-full", roomState, verification };
  }

  let target = resolveFillTarget(
    creep,
    creep.memory.energyFillTargetId
  );

  if (!target) {
    target = selectFillTarget(creep);
    creep.memory.energyFillTargetId = target?.id ?? null;
  }

  if (!target) {
    return {
      status: "no-reachable-fill-target",
      roomState,
      verification
    };
  }

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

    if (moveResult !== OK) {
      creep.memory.energyFillTargetId = null;
    }

    return {
      status: "moving-to-energy-target",
      targetId: target.id,
      moveResult,
      roomState,
      verification
    };
  }

  const targetBefore = target.store.getUsedCapacity(RESOURCE_ENERGY);
  const freeNow = target.store.getFreeCapacity(RESOURCE_ENERGY);
  const carriedNow = creep.store.getUsedCapacity(RESOURCE_ENERGY);
  const requestedAmount = Math.min(carriedNow, freeNow);

  if (requestedAmount <= 0) {
    creep.memory.energyFillTargetId = null;
    return {
      status: "target-changed-before-transfer",
      targetId: target.id,
      roomState,
      verification
    };
  }

  const result = creep.transfer(
    target,
    RESOURCE_ENERGY,
    requestedAmount
  );

  if (result === OK) {
    getEnergyFillMemory().pending[creep.name] = {
      tick: Game.time,
      roomName: creep.room.name,
      creepId: creep.id,
      targetId: target.id,
      requestedAmount,
      targetBefore,
      creepBefore: carriedNow,
      roomBefore: creep.room.energyAvailable
    };
  } else {
    creep.memory.energyFillTargetId = null;
  }

  return {
    status: result === OK ? "transfer-submitted" : "transfer-failed",
    result,
    targetId: target.id,
    requestedAmount,
    roomState,
    verification
  };
}

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

  const outcome = runSpawnEnergyFiller(creep);
  if (
    outcome.status === "transfer-failed"
    || outcome.status === "no-reachable-fill-target"
  ) {
    console.log(JSON.stringify({
      type: "spawn-energy-fill-problem",
      tick: Game.time,
      creepName: creep.name,
      ...outcome
    }));
  }
};

The checked engine's transfer processor re-reads the target's current capacity while processing the intent and can reduce the processed amount if the target filled in the meantime. The processor records the actual amount in EVENT_TRANSFER. For this reason, keep requestedAmount and processedAmount as different fields.

An OK return is submission evidence. The exact next-tick event is processed-result evidence. The room aggregate is operational context, not proof of that one transfer.

transfer() return-code checklist

CodeMeaning in this workflowResponse
OKThe transfer was scheduled successfully.Match the exact previous-tick event on the next tick.
ERR_NOT_OWNERThe sending Creep is not yours.Stop; do not retry ownership failures as logistics.
ERR_BUSYThe Creep is still spawning.Wait.
ERR_NOT_ENOUGH_RESOURCESThe Creep lacks the requested Energy.Re-read carried Energy and recompute the amount.
ERR_INVALID_TARGETThe target cannot receive this transfer.Clear the saved ID and rebuild the candidate set.
ERR_FULLThe target has no free capacity in the current snapshot.Clear the target and select again.
ERR_NOT_IN_RANGEThe target is not adjacent.Keep the movement result separate from the transfer result.
ERR_INVALID_ARGSThe resource type or amount is invalid.Recompute from current Stores.

Evidence and engine boundaries

The official Room API, Creep.transfer(), Room.getEventLog(), and current screeps/engine 4.3.2 source were rechecked on August 18, 2026. The engine master checked for this revision is 80977824199a596d174d392fd0cf8c458c21fcbd.

Engine-source boundary: the current runtime builds room.energyAvailable and room.energyCapacityAvailable from owned Spawn/Extension objects that are not off. The article uses public isActive() rather than relying on that internal field.

Concurrency boundary: a transfer event proves that one transfer processed, but it does not prove the room total had to rise by the same amount. Spawn consumption or other transfers can change the same aggregate around the observation window.

Live evidence: Screeps Console test: Pending. Live multi-hauler contention trace: Pending. Live transfer-plus-spawn-consumption trace: Pending. No live result is fabricated.

Continue with dynamic Creep bodies, spawnCreep() return codes, Room event logs, or Storage Energy policy.

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.