Skip to content

fix ige snapshot sharing state with the live handler - #7

Open
dynamite885 wants to merge 1 commit into
halp1:mainfrom
dynamite885:fix/ige-snapshot-aliasing
Open

fix ige snapshot sharing state with the live handler#7
dynamite885 wants to merge 1 commit into
halp1:mainfrom
dynamite885:fix/ige-snapshot-aliasing

Conversation

@dynamite885

Copy link
Copy Markdown

IGEHandler#snapshot() returns the player entries by reference, so a snapshot keeps changing along with the live handler.

snapshot(): IGEHandlerSnapshot {
  return {
    players: Object.fromEntries(this.#players.entries()), // values are shared
    iid: this.#iid
  };
}

Object.fromEntries builds a new object, but the PlayerData values are the same references the map holds. receive() then mutates them in place:

const item = player.outgoing[i];
const amt = Math.min(item.amount, runningAmount);
item.amount -= amt;

So after taking a snapshot and continuing to play, the snapshot's outgoing amounts have been decremented too. Restoring it leaves less cancellation available than there should be, and more garbage lands than the replay actually had.

fromSnapshot() has the mirror problem — it inserts the given objects straight into the live map, so a snapshot that is restored more than once gets corrupted by the run in between.

How I ran into it

I'm building a replay analysis tool on the engine. It keeps periodic snapshots so playback can be rewound, and it snapshots/restores around a short forward scan to find the next lock frame, for piece-by-piece stepping.

Stepping piece by piece and seeking directly to the same frame produced different boards. On one 1v1 replay, at one piece the pending garbage was 16 when stepped into but 11 when sought directly. Stepping back and forward again gave 11, because going backwards re-derives from an earlier snapshot instead of continuing from the corrupted one.

Stated generally: any two navigation paths to the same frame should end in the same engine state. With the current snapshot they don't.

The change

Deep copies on both sides, using the existing deepCopy rather than structuredClone, given the note in deepCopy about it being slow.

I kept this to ige.ts. While tracking it down I also noticed Tetromino#snapshot() returns keys and fallingRotations by reference, and Engine#snapshot() passes multiplayer?.targets through uncopied. Those look like the same class of issue, but I haven't confirmed they cause visible problems, so I left them alone — happy to follow up separately.

Let me know if you'd prefer a different shape for this.

snapshot() returned the player entries by reference, so a snapshot kept changing along with the live handler: receive() decrements item.amount on the very objects the snapshot holds. Restoring one left less cancellation available than it should, so more garbage landed. fromSnapshot() had the mirror problem, inserting the given objects straight into the live map.
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

@dynamite885 is attempting to deploy a commit to the Haelp Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant