MEMORY · CROSS-SHARD STATE

Screeps InterShardMemory: Versioned Cross-Shard State Without Remote Writes

Verification statusOfficial API: Checked — InterShardMemory.getLocal(), setLocal(), getRemote(), the 100 KB shard-owned string, and remote read-only scope · Shard identity: Checked — Game.shard.name identifies the shard executing the current script · JavaScript syntax: Passed by the article simulation gate

VERIFICATION

Evidence and test status

Official API
Checked — InterShardMemory.getLocal(), setLocal(), getRemote(), the 100 KB shard-owned string, and remote read-only scope
Shard identity
Checked — Game.shard.name identifies the shard executing the current script
JavaScript syntax
Passed by the article simulation gate
Offline state cases
Passed — byte counts, parsing, schema and source guards, revisions, writer epochs, stale observations, size rejection, and handoff identity
Screeps Console test
Pending
Official-shard propagation test
Pending
Evidence level
Official source review, repository integration, syntax checks and deterministic offline simulation only

Quick answer

Treat InterShardMemory as one versioned, shard-owned publication per shard. The current shard may replace only its own string; other shards can read that string but cannot edit it. Put a schema version, source shard, writer epoch, envelope revision, and channel revisions around the data, enforce a conservative UTF-8 byte budget, and keep remote freshness in the receiver's local Memory.

Do not subtract a remote writtenAtTick from the local Game.time unless you have separately proven that clock relationship. The official API documents isolated shard execution and storage, but it does not promise one shared tick clock for application-level freshness decisions.

Separate Memory, Segments, and InterShardMemory

ContainerWrite scopePrimary use
MemoryCurrent shardFrequently used persistent state
RawMemory.segmentsCurrent shardLarge data activated on demand
InterShardMemoryCurrent shard writes its own string; remote strings are read-onlyCross-shard state exchange

The API currently gives every shard a separate 100 KB string. setLocal() replaces the whole local string, so one coordinator must merge all channel updates before writing.

Create a writer epoch for stream restarts

function createWriterEpoch(shardName, now) {
  if (
    typeof shardName !== 'string'
    || shardName.trim() === ''
    || !Number.isInteger(now)
  ) {
    return null;
  }

  return shardName + ':' + now;
}

A revision can restart at zero after a local reset or migration. The writer epoch distinguishes a new stream from an invalid revision regression. Reuse the existing epoch while the local envelope is valid; create a new one only when starting a new local stream.

Measure UTF-8 bytes before setLocal()

function utf8ByteLength(value) {
  const text = String(value);
  let bytes = 0;

  for (let index = 0; index < text.length; index += 1) {
    const code = text.charCodeAt(index);

    if (code < 0x80) {
      bytes += 1;
    } else if (code < 0x800) {
      bytes += 2;
    } else if (
      code >= 0xD800
      && code <= 0xDBFF
      && index + 1 < text.length
      && text.charCodeAt(index + 1) >= 0xDC00
      && text.charCodeAt(index + 1) <= 0xDFFF
    ) {
      bytes += 4;
      index += 1;
    } else {
      bytes += 3;
    }
  }

  return bytes;
}

JavaScript string length counts UTF-16 code units, not UTF-8 bytes. The official API documents a 100 KB string but the reviewed documentation does not define the server-side accounting algorithm. This guide therefore uses exact UTF-8 accounting only for its own conservative 96 KiB project budget; 96 KiB is not presented as another official Screeps limit.

Define one versioned envelope

function createEmptyEnvelope(
  shardName,
  writerEpoch,
  now
) {
  return {
    schemaVersion: 1,
    sourceShard: shardName,
    writerEpoch,
    revision: 0,
    writtenAtTick: now,
    channels: {}
  };
}

The envelope owns global schema and stream identity. Each business channel should also own a monotonically increasing channel revision so unrelated channel updates do not make stale data appear fresh.

Parse without erasing damaged evidence

function parseInterShardEnvelope(
  raw,
  expectedShard
) {
  if (raw == null || raw === '') {
    return {
      status: 'empty',
      envelope: null
    };
  }

  if (typeof raw !== 'string') {
    return {
      status: 'invalid-raw-type',
      envelope: null
    };
  }

  let value;

  try {
    value = JSON.parse(raw);
  } catch {
    return {
      status: 'invalid-json',
      envelope: null
    };
  }

  if (
    !value
    || typeof value !== 'object'
    || Array.isArray(value)
  ) {
    return {
      status: 'invalid-envelope',
      envelope: null
    };
  }

  if (value.schemaVersion !== 1) {
    return {
      status: 'unsupported-schema',
      envelope: null
    };
  }

  if (value.sourceShard !== expectedShard) {
    return {
      status: 'source-shard-mismatch',
      envelope: null
    };
  }

  if (
    typeof value.writerEpoch !== 'string'
    || value.writerEpoch === ''
    || !Number.isInteger(value.revision)
    || value.revision < 0
    || !value.channels
    || typeof value.channels !== 'object'
    || Array.isArray(value.channels)
  ) {
    return {
      status: 'invalid-envelope-fields',
      envelope: null
    };
  }

  return {
    status: 'valid',
    envelope: value
  };
}

Keep empty data, invalid JSON, unsupported schemas, source mismatches, and malformed fields as separate states. Do not turn a damaged local string into an empty object and immediately overwrite it.

Load and publish the local document

let interShardDraftTick = null;
let interShardDraftEnvelope = null;

function loadLocalEnvelope() {
  const shardName = Game.shard.name;

  if (
    interShardDraftTick === Game.time
    && interShardDraftEnvelope
  ) {
    return {
      status: 'loaded-draft',
      envelope: interShardDraftEnvelope
    };
  }

  const raw = InterShardMemory.getLocal();
  const parsed = parseInterShardEnvelope(
    raw,
    shardName
  );

  if (parsed.status === 'valid') {
    Memory.interShard ??= {};
    Memory.interShard.writerEpoch =
      parsed.envelope.writerEpoch;
    interShardDraftTick = Game.time;
    interShardDraftEnvelope = parsed.envelope;

    return {
      status: 'loaded',
      envelope: parsed.envelope
    };
  }

  if (parsed.status !== 'empty') {
    return {
      status: 'local-data-invalid',
      reason: parsed.status,
      envelope: null
    };
  }

  Memory.interShard ??= {};
  const writerEpoch = createWriterEpoch(
    shardName,
    Game.time
  );

  if (writerEpoch === null) {
    return {
      status: 'writer-epoch-unavailable',
      envelope: null
    };
  }

  Memory.interShard.writerEpoch = writerEpoch;

  const envelope = createEmptyEnvelope(
    shardName,
    writerEpoch,
    Game.time
  );
  interShardDraftTick = Game.time;
  interShardDraftEnvelope = envelope;

  return {
    status: 'created-empty',
    envelope
  };
}
const INTERSHARD_SAFE_BYTE_LIMIT = 96 * 1024;

function publishLocalChannel(
  channelName,
  nextValue
) {
  if (
    typeof channelName !== 'string'
    || channelName.trim() === ''
  ) {
    return {
      status: 'invalid-channel-name'
    };
  }

  const loaded = loadLocalEnvelope();

  if (!loaded.envelope) {
    return loaded;
  }

  const previousChannel =
    loaded.envelope.channels[channelName];
  const nextChannelRevision =
    Number.isInteger(previousChannel?.revision)
      ? previousChannel.revision + 1
      : 1;

  const nextEnvelope = {
    ...loaded.envelope,
    revision: loaded.envelope.revision + 1,
    writtenAtTick: Game.time,
    channels: {
      ...loaded.envelope.channels,
      [channelName]: {
        revision: nextChannelRevision,
        updatedAtTick: Game.time,
        value: nextValue
      }
    }
  };
  const serialized = JSON.stringify(nextEnvelope);
  const byteLength = utf8ByteLength(serialized);

  if (byteLength > INTERSHARD_SAFE_BYTE_LIMIT) {
    return {
      status: 'payload-too-large',
      byteLength
    };
  }

  InterShardMemory.setLocal(serialized);
  interShardDraftTick = Game.time;
  interShardDraftEnvelope = nextEnvelope;

  return {
    status: 'local-write-called',
    byteLength,
    envelopeRevision: nextEnvelope.revision,
    channelRevision: nextChannelRevision
  };
}

setLocal() has no documented OK return code. The coordinator above also keeps the newest envelope in a tick-scoped draft, so later channel updates merge that staged document instead of assuming a documented setLocal()-then-getLocal() read-after-write contract. The status local-write-called therefore describes only the local function call. It is not evidence that another shard has already observed the new revision.

Read a remote channel as read-only data

function readRemoteChannel(
  remoteShard,
  channelName
) {
  if (
    typeof remoteShard !== 'string'
    || remoteShard === ''
    || remoteShard === Game.shard.name
  ) {
    return {
      status: 'invalid-remote-shard'
    };
  }

  const raw = InterShardMemory.getRemote(
    remoteShard
  );
  const parsed = parseInterShardEnvelope(
    raw,
    remoteShard
  );

  if (parsed.status !== 'valid') {
    return {
      status: parsed.status,
      remoteShard
    };
  }

  const channel =
    parsed.envelope.channels[channelName];

  if (
    !channel
    || !Number.isInteger(channel.revision)
    || channel.revision < 0
  ) {
    return {
      status: 'channel-missing',
      remoteShard,
      writerEpoch:
        parsed.envelope.writerEpoch
    };
  }

  return {
    status: 'channel-read',
    remoteShard,
    writerEpoch:
      parsed.envelope.writerEpoch,
    envelopeRevision:
      parsed.envelope.revision,
    channelRevision: channel.revision,
    value: channel.value
  };
}

Validate the declared source shard before accepting a remote channel. A shard must never attempt to update another shard's publication; acknowledgements and responses belong in the receiver's own local document.

Use a local observation window

function observeRemoteChannel(
  remoteShard,
  channelName,
  maxSilentTicks = 100
) {
  const result = readRemoteChannel(
    remoteShard,
    channelName
  );

  if (result.status !== 'channel-read') {
    return result;
  }

  Memory.interShardObservers ??= {};
  const key = remoteShard + ':' + channelName;
  const previous =
    Memory.interShardObservers[key];
  const streamChanged =
    !previous
    || previous.writerEpoch
      !== result.writerEpoch;

  if (
    !streamChanged
    && result.channelRevision
      < previous.channelRevision
  ) {
    return {
      status: 'revision-regressed',
      remoteShard,
      channelName,
      previousRevision:
        previous.channelRevision,
      observedRevision:
        result.channelRevision
    };
  }

  const advanced =
    streamChanged
    || result.channelRevision
      > previous.channelRevision;
  const next = {
    writerEpoch: result.writerEpoch,
    channelRevision:
      result.channelRevision,
    lastCheckedAt: Game.time,
    lastAdvancedAt: advanced
      ? Game.time
      : previous.lastAdvancedAt
  };

  Memory.interShardObservers[key] = next;

  if (
    !advanced
    && Game.time - next.lastAdvancedAt
      > maxSilentTicks
  ) {
    return {
      ...result,
      status: 'channel-stale',
      silentTicks:
        Game.time - next.lastAdvancedAt
    };
  }

  return {
    ...result,
    status: streamChanged
      ? 'stream-started'
      : advanced
        ? 'channel-advanced'
        : 'channel-unchanged',
    silentTicks:
      Game.time - next.lastAdvancedAt
  };
}

The receiver records when a remote channel revision last advanced in its own local tick space. An unchanged revision can become channel-stale, but that state means only that this receiver has not observed progress within its configured window. It does not prove that the remote shard is offline.

Use offer and acknowledgement channels

function buildOutboundHandoff(
  creep,
  targetShard
) {
  if (
    !creep
    || creep.my !== true
    || typeof targetShard !== 'string'
    || targetShard === ''
  ) {
    return null;
  }

  return {
    handoffId:
      Game.shard.name
      + ':'
      + creep.name
      + ':'
      + Game.time,
    creepName: creep.name,
    sourceShard: Game.shard.name,
    targetShard,
    state: 'offered',
    offeredAtTick: Game.time,
    memory: {
      role: creep.memory.role ?? null,
      missionId:
        creep.memory.missionId ?? null
    }
  };
}

buildOutboundHandoff() only creates one offer record. The source coordinator should merge it into the existing handoffOffers map, apply pruneRecordMap(), and publish the bounded map from its own shard rather than replacing other unresolved offers.

function acknowledgeRemoteHandoff(
  offer,
  observedCreep
) {
  if (
    !offer
    || offer.targetShard
      !== Game.shard.name
    || !observedCreep
    || observedCreep.my !== true
    || observedCreep.name
      !== offer.creepName
  ) {
    return {
      status: 'handoff-not-confirmed'
    };
  }

  const acknowledgement = {
    handoffId: offer.handoffId,
    sourceShard: offer.sourceShard,
    targetShard: Game.shard.name,
    creepName: observedCreep.name,
    state: 'observed-on-target',
    observedAtTick: Game.time
  };

  const loaded = loadLocalEnvelope();

  if (!loaded.envelope) {
    return loaded;
  }

  const previousValue =
    loaded.envelope.channels
      .handoffAcknowledgements?.value;
  const previousRecords =
    previousValue
    && typeof previousValue === 'object'
    && !Array.isArray(previousValue)
      ? previousValue
      : {};

  return publishLocalChannel(
    'handoffAcknowledgements',
    pruneRecordMap({
      ...previousRecords,
      [offer.handoffId]: acknowledgement
    })
  );
}

For a Creep handoff, the source publishes an offer. The destination reads it, waits until it observes the exact Creep, then publishes an acknowledgement in its own local channel. The source later reads that acknowledgement. Neither side writes the other's data.

Keep the publication bounded

function pruneRecordMap(
  records,
  maxRecords = 32
) {
  if (
    !records
    || typeof records !== 'object'
    || Array.isArray(records)
  ) {
    return {};
  }

  return Object.fromEntries(
    Object.entries(records)
      .sort((left, right) => {
        const leftTick =
          left[1]?.updatedAtTick
          ?? left[1]?.observedAtTick
          ?? left[1]?.offeredAtTick
          ?? 0;
        const rightTick =
          right[1]?.updatedAtTick
          ?? right[1]?.observedAtTick
          ?? right[1]?.offeredAtTick
          ?? 0;

        return rightTick - leftTick
          || left[0].localeCompare(right[0]);
      })
      .slice(0, maxRecords)
  );
}

Store current snapshots, unresolved messages, and only a small completion history. Never serialize complete Room, Creep, or structure objects, and do not place external authentication secrets in this synchronization layer.

Build the complete publication loop

function runInterShardSync(
  remoteShards
) {
  const localStatus = {
    shard: Game.shard.name,
    tick: Game.time,
    ownedRooms: Object.values(Game.rooms)
      .filter(room => room.controller?.my)
      .map(room => room.name)
      .sort()
  };
  const publication = publishLocalChannel(
    'empireStatus',
    localStatus
  );
  const observations = [];

  for (const remoteShard of remoteShards) {
    observations.push(
      observeRemoteChannel(
        remoteShard,
        'empireStatus',
        100
      )
    );
  }

  return {
    publication,
    observations
  };
}

This loop publishes the current shard's owned-room names and observes the same channel from configured remote shards. It intentionally makes no fixed propagation-delay claim.

Common failure modes

  • Shared writable-object assumption: remote shard strings are read-only.
  • Blind JSON.parse(): empty, corrupt, old-schema, and wrong-source states collapse together.
  • Partial setLocal update: another channel disappears because the entire string was replaced.
  • String length as byte length: non-ASCII payloads can exceed the real byte budget.
  • Cross-shard tick subtraction: freshness depends on an undocumented clock assumption.
  • Revision without writer epoch: a valid stream restart looks like permanent regression.
  • setLocal means synchronized: a local call is confused with later remote observation.
  • Unbounded message history: serialization work and payload size grow forever.

Evidence and production boundary

This guide is based on the current official InterShardMemory and Game.shard APIs. Repository tests syntax-check the examples and simulate UTF-8 byte counts, damaged inputs, schema and source validation, revisions, writer-epoch changes, regressions, local observation freshness, size rejection, and handoff identity checks.

Official-shard propagation delay, real portal traversal, cross-shard CPU cost, restricted-shard access behavior, and live multi-tick evidence remain Pending.

Official references: InterShardMemory, Game.shard, Global Objects and Memory, and the game loop.

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.