LOGISTICS · LINK ENERGY COORDINATION

Coordinate Link Transfers and Verify the Exact Source-Target Event

Verification statusExisting English route: Preserved — no URL, slug, Canonical, or Chinese mapping change · Official Screeps API: Reviewed — StructureLink.transferEnergy(), cooldown, same-room boundary, loss constant, event log, and return codes · Project-policy boundary: Link roles, priorities, minimumSend, targetReserve, and dispatch cadence are room policy

VERIFICATION

Evidence and test status

Existing English route
Preserved — no URL, slug, Canonical, or Chinese mapping change
Official Screeps API
Reviewed — StructureLink.transferEnergy(), cooldown, same-room boundary, loss constant, event log, and return codes
Project-policy boundary
Link roles, priorities, minimumSend, targetReserve, and dispatch cadence are room policy
Static validation
Shared reservation arithmetic, one-source submission, event identity, missed-window handling, and JavaScript syntax reviewed
Validation level
Official API review, JavaScript syntax review, static control-flow review, and repository checks
Screeps Console test
Pending
Live multi-tick verification
Pending
Genuine room or Console screenshots
Pending
Official engine source
Checked screeps/engine 80977824199a596d174d392fd0cf8c458c21fcbd: Link processing re-reads target capacity, truncates to current free space when needed, then applies loss to the processed amount
Capacity reservation policy
Project policy — reserve requested Energy, not predicted post-loss receipt, so same-tick planning does not rely on processor order to reuse loss-created capacity
Amount and loss boundary
A matched EVENT_TRANSFER amount is treated as processed Energy; loss is reconciled from that processed amount rather than assumed from the request
Screeps Console test
Pending — no live same-tick multi-Link truncation trace is claimed
Live requested-versus-processed comparison
Pending — no controlled target-capacity contention run was collected
Last editorial review
August 17, 2026
Publication status
Published

Use this guide when

You have two or more owned Links in one room and need one dispatcher to decide which source sends, how much target capacity is still available, and whether the next tick contains the exact source-to-target transfer event.

This guide assumes Link roles are already configured by ID. It does not try to infer a Controller Link or Source Link from array order or position.

Why independent senders race

Two modules can read the same target free capacity before either transfer settles. Both may decide that the full amount is available. A later call can then return ERR_FULL, or one module can overwrite the evidence record of another.

A local reservation is not a server lock. It is a same-tick planning rule used by one dispatcher so every source evaluates the target against capacity already promised by earlier plans.

Resolve explicit Link identities

function getOwnedLink(id, roomName) {
  if (typeof id !== 'string') {
    return null;
  }

  const link = Game.getObjectById(id);
  if (
    !link
    || link.structureType !== STRUCTURE_LINK
    || link.my !== true
    || link.room.name !== roomName
  ) {
    return null;
  }

  return link;
}

function estimateLinkReceipt(requestedAmount) {
  if (!Number.isInteger(requestedAmount) || requestedAmount <= 0) {
    return {
      requestedAmount: 0,
      estimatedLoss: 0,
      estimatedReceived: 0
    };
  }

  const estimatedLoss = Math.ceil(
    requestedAmount * LINK_LOSS_RATIO
  );

  return {
    requestedAmount,
    estimatedLoss,
    estimatedReceived: Math.max(
      0,
      requestedAmount - estimatedLoss
    )
  };
}

The loss calculation is useful for planning and logs. The next-tick event and current Stores remain the evidence. Do not rewrite a predicted target Store value into Memory.

Reserve shared target capacity

The planner below sorts requests once, rejects inactive or cooling source Links, and reduces the target's remaining capacity after every accepted plan.

function planLinkTransfers(roomName, config) {
  const target = getOwnedLink(
    config.targetLinkId,
    roomName
  );

  if (!target || target.isActive() !== true) {
    return {
      status: 'target-unavailable',
      target: null,
      plans: []
    };
  }

  const targetReserve = Number.isInteger(config.targetReserve)
    ? Math.max(0, config.targetReserve)
    : 0;
  const minimumSend = Number.isInteger(config.minimumSend)
    ? Math.max(1, config.minimumSend)
    : 1;
  let remainingTargetCapacity = Math.max(
    0,
    target.store.getFreeCapacity(RESOURCE_ENERGY)
      - targetReserve
  );

  const requests = [...config.sources]
    .filter(request =>
      typeof request.sourceLinkId === 'string'
      && Number.isFinite(request.priority)
    )
    .sort((left, right) =>
      right.priority - left.priority
      || left.sourceLinkId.localeCompare(
        right.sourceLinkId
      )
    );

  const seenSources = new Set();
  const plans = [];

  for (const request of requests) {
    if (seenSources.has(request.sourceLinkId)) {
      continue;
    }
    seenSources.add(request.sourceLinkId);

    const source = getOwnedLink(
      request.sourceLinkId,
      roomName
    );
    if (
      !source
      || source.id === target.id
      || source.isActive() !== true
      || source.cooldown > 0
    ) {
      continue;
    }

    const sourceEnergy = source.store.getUsedCapacity(
      RESOURCE_ENERGY
    );
    const requestedAmount = Math.min(
      sourceEnergy,
      remainingTargetCapacity
    );

    if (requestedAmount < minimumSend) {
      continue;
    }

    const estimate = estimateLinkReceipt(requestedAmount);
    plans.push({
      sourceId: source.id,
      targetId: target.id,
      requestedAmount,
      estimate
    });

    remainingTargetCapacity = Math.max(
      0,
      remainingTargetCapacity
        - requestedAmount
    );
  }

  return {
    status: plans.length > 0
      ? 'plans-ready'
      : 'no-transfer-ready',
    target,
    plans,
    targetReserve,
    minimumSend,
    remainingTargetCapacity
  };
}

The requested amount is deliberately conservative: it never exceeds visible source stock or unreserved target capacity. The reservation subtracts the requested amount, not the predicted post-loss receipt. That means this planner does not recycle loss-created capacity inside the same planning pass or depend on Link intent-processing order to make later requests fit. It can leave target capacity unused after loss, which is an intentional safety tradeoff.

Submit each source once

function submitLinkPlans(roomName, state, planned) {
  const submissions = [];

  for (const plan of planned.plans) {
    const source = getOwnedLink(plan.sourceId, roomName);
    const target = getOwnedLink(plan.targetId, roomName);

    if (!source || !target) {
      submissions.push({
        status: 'link-disappeared',
        sourceId: plan.sourceId,
        targetId: plan.targetId
      });
      continue;
    }

    const before = {
      sourceEnergy: source.store.getUsedCapacity(
        RESOURCE_ENERGY
      ),
      targetEnergy: target.store.getUsedCapacity(
        RESOURCE_ENERGY
      ),
      targetFree: target.store.getFreeCapacity(
        RESOURCE_ENERGY
      ),
      sourceCooldown: source.cooldown
    };
    const result = source.transferEnergy(
      target,
      plan.requestedAmount
    );

    const submission = {
      requestId: [
        roomName,
        Game.time,
        source.id,
        target.id
      ].join(':'),
      submittedAt: Game.time,
      sourceId: source.id,
      targetId: target.id,
      requestedAmount: plan.requestedAmount,
      estimate: plan.estimate,
      result,
      before
    };

    if (result === OK) {
      state.pending.push(submission);
    }

    submissions.push({
      ...submission,
      status: result === OK
        ? 'transfer-accepted'
        : 'transfer-rejected'
    });
  }

  return submissions;
}

The planner deduplicates source IDs, and the dispatcher is the only module that calls transferEnergy(). A source Link that is accepted becomes unavailable through cooldown; same-tick coordination should not depend on discovering that after another caller has already acted.

Separate requested Link Energy from processed Energy

transferEnergy() returning OK proves that the request passed the call-time checks. It does not freeze the target Store for processing. In the checked engine snapshot, the Link processor re-reads the target's current Energy and capacity and can truncate the processed amount to the free capacity that remains at processing time.

The processor applies Math.ceil(processedAmount × LINK_LOSS_RATIO) after that truncation, so loss should be reconciled from the processed event amount, not blindly from the original request. The source cooldown is based on Link distance, not on how much Energy ultimately fits. A matched EVENT_TRANSFER therefore carries the stronger amount evidence for the processed source-target action.

Verify exact transfer events

For every accepted call, match the next tick's EVENT_TRANSFER by source object ID, target ID, and Energy resource type. Event-reported amount and net Store changes are retained as separate fields.

function verifyLinkTransfers(room, state) {
  const events = room.getEventLog();
  const results = [];
  const keep = [];

  for (const pending of state.pending) {
    if (Game.time <= pending.submittedAt) {
      keep.push(pending);
      continue;
    }

    if (Game.time !== pending.submittedAt + 1) {
      results.push({
        status: 'verification-window-missed',
        requestId: pending.requestId
      });
      continue;
    }

    const matches = events.filter(event =>
      event.event === EVENT_TRANSFER
      && event.objectId === pending.sourceId
      && event.data?.targetId === pending.targetId
      && event.data?.resourceType === RESOURCE_ENERGY
    );
    const source = Game.getObjectById(pending.sourceId);
    const target = Game.getObjectById(pending.targetId);
    const netState = {
      sourceEnergyNow: source
        ? source.store.getUsedCapacity(RESOURCE_ENERGY)
        : null,
      targetEnergyNow: target
        ? target.store.getUsedCapacity(RESOURCE_ENERGY)
        : null,
      sourceCooldownNow: source?.cooldown ?? null
    };

    if (matches.length === 1) {
      results.push({
        status: 'transfer-event-verified',
        requestId: pending.requestId,
        requestedAmount: pending.requestedAmount,
        processedAmount: matches[0].data?.amount ?? null,
        processedEstimate: estimateLinkReceipt(
          matches[0].data?.amount ?? 0
        ),
        eventData: matches[0].data || null,
        before: pending.before,
        netState
      });
    } else {
      results.push({
        status: matches.length === 0
          ? 'accepted-event-not-found'
          : 'ambiguous-transfer-events',
        requestId: pending.requestId,
        matchCount: matches.length,
        netState
      });
    }
  }

  state.pending = keep;
  return results;
}

Another Creep or Link may change either Store in the same tick. Exact event identity proves the actor-target pair; Store deltas remain operational context rather than exclusive attribution.

Complete Link-network loop

function runLinkNetwork(roomName) {
  Memory.linkNetworks ??= {};
  const state = Memory.linkNetworks[roomName] ??= {
    enabled: true,
    targetLinkId: 'replace-with-target-link-id',
    targetReserve: 0,
    minimumSend: 200,
    sources: [],
    pending: []
  };

  if (state.enabled !== true) {
    return { status: 'disabled' };
  }

  state.pending = Array.isArray(state.pending)
    ? state.pending
    : [];

  if (state.lastDispatchAt === Game.time) {
    return { status: 'already-dispatched-this-tick' };
  }
  state.lastDispatchAt = Game.time;

  const room = Game.rooms[roomName];
  if (!room) {
    return { status: 'room-not-visible' };
  }

  const verification = verifyLinkTransfers(room, state);
  const planned = planLinkTransfers(roomName, state);
  const submissions = planned.status === 'plans-ready'
    ? submitLinkPlans(roomName, state, planned)
    : [];

  state.lastRunAt = Game.time;
  state.lastPlanStatus = planned.status;
  state.lastVerification = verification;

  return {
    status: submissions.length > 0
      ? 'submissions-complete'
      : planned.status,
    verification,
    submissions,
    remainingTargetCapacity:
      planned.remainingTargetCapacity ?? null
  };
}

module.exports.loop = function () {
  const outcome = runLinkNetwork('W1N1');

  if (
    outcome.status === 'target-unavailable'
    || outcome.submissions?.some(
      item => item.status === 'transfer-rejected'
    )
    || Game.time % 100 === 0
  ) {
    console.log(JSON.stringify({
      type: 'link-network',
      gameTick: Game.time,
      outcome
    }));
  }
};

Example source configuration:

Memory.linkNetworks.W1N1.sources = [
  {
    sourceLinkId: 'replace-with-first-source-link-id',
    priority: 100
  },
  {
    sourceLinkId: 'replace-with-second-source-link-id',
    priority: 80
  }
];

Return-code boundaries

ResultLikely boundaryReview
OKThe transfer intent was acceptedMatch the next-tick event
ERR_NOT_OWNERThe source Link is not yoursSource ID and ownership
ERR_NOT_ENOUGH_RESOURCESSource Energy changed before the callCurrent source Store and competing code
ERR_INVALID_TARGETThe target is not a valid LinkCurrent target object and type
ERR_FULLThe target cannot accept the requested transferShared reservations and other callers
ERR_NOT_IN_RANGEThe Links are not in the same roomBoth room names
ERR_INVALID_ARGSThe amount is invalidPositive integer request
ERR_TIREDThe source Link is cooling downsource.cooldown
ERR_RCL_NOT_ENOUGHA Link is inactive at the current RCLisActive() and room level

Production adaptation notes

  • Use one dispatcher for every Link action in the room. A reservation map cannot coordinate code that bypasses it.
  • Priorities, reserve, and minimum send are policies. A Controller Link, Storage Link, and upgrade burst do not share one universal threshold.
  • Path and range do not determine Link cooldown in this article. Read the live cooldown after settlement rather than logging a guessed ready tick.
  • Keep accepted requests until exactly the next tick. Do not retain them indefinitely and later claim a matching net Store change.
  • Private servers may change constants. Treat the current environment's constants as the calculation source.

Choose another guide when

Use the Controller recovery guide when Energy reaches the Controller area but upgrade intents are still not verified. Use the Storage Energy policy guide when the unresolved decision is whether the room can afford the transfer. Use the event-log ingestion guide when several systems need one shared, idempotent event reader.

Official documentation

SOURCE AND SCOPE

This English guide keeps the technical scope and verification boundaries of its Chinese source. The verification record shows which checks were completed and which runtime observations are still pending.