--:--

Mutating an Array While Iterating Over It

When I worked on a snake game at the company, I ran into a classic bug that was hard to spot in a large codebase: mutating an array while iterating over it. Here’s the example:

  roomData.players.forEach((player) => {
    console.log(...roomData.players);
    console.log(player.id);
    console.log("[DEBUG]: " + roomData.players.length);
    this.killPlayer(player.id, DeathReason.TimesUp);
  });

Output:

app-1  | { id: 'o-YtjCYy050TW9H0AAAB', type: 'snake', avatar: 'snake4' } { id: 'swzaRwreukn6J--NAAAD', type: 'human', avatar: 'human1' }
app-1  | o-YtjCYy050TW9H0AAAB                                                                                                                                                    
app-1  | [DEBUG]: 2

What happened

In this example, I’m iterating through each player in roomData. The first console.log shows 2 players, and roomData.players.length also shows 2, but console.log(player.id); only runs once.

Why it happens

The reason is that killPlayer runs while I’m still iterating, and it removes a player from roomData. So even though the array started with 2 players, one gets removed during the loop, which is why only one log appears.

This is a classic bug you can run into when deleting elements from an array while iterating over it.

How to solve it

There is usually more than one way to solve a problem, unless the problem is just boolean, I mean binary. :D

Solution 1: iterate backwards

Iterate from the end of the array back to 0. The length changes every time an element is deleted, but the index still moves safely backward.

Example:

for (let i = roomData.players.length - 1; i >= 0; i--) {
  const player = roomData.players[i];
  console.log(...roomData.players);
  console.log(player);
  console.log("[DEBUG]: " + roomData.players.length);
  this.killPlayer(player.id, DeathReason.TimesUp);
}

Output:

{ id: 'hLxAlYSUHVmryFyyAAAD', type: 'snake', avatar: 'snake4' } { id: 'Zw6lzr0fQW-rQQ3tAAAF', type: 'human', avatar: 'human2' } { id: 'Dpq0vcWR1C6jwYKvAAAJ', type: 'human', avatar: 'human1' }                                                                                                                                
app-1  | { id: 'Dpq0vcWR1C6jwYKvAAAJ', type: 'human', avatar: 'human1' }
app-1  | [DEBUG]: 3                                                                                                                                                 
app-1  | [DEATH] Game: 1766979246579-jdwemtzz8xf, Socket: Dpq0vcWR1C6jwYKvAAAJ, Player: human1, Reason: timesUp, Position: (615, 435)                               
app-1  | [DISCONNECT] Player "human1" with socket Dpq0vcWR1C6jwYKvAAAJ disconnected in room 1766979246579-jdwemtzz8xf.                                              
app-1  | { id: 'hLxAlYSUHVmryFyyAAAD', type: 'snake', avatar: 'snake4' } { id: 'Zw6lzr0fQW-rQQ3tAAAF', type: 'human', avatar: 'human2' }                            
app-1  | { id: 'Zw6lzr0fQW-rQQ3tAAAF', type: 'human', avatar: 'human2' }                                                                                            
app-1  | [DEBUG]: 2                                                                                                                                                 
app-1  | [DEATH] Game: 1766979246579-jdwemtzz8xf, Socket: Zw6lzr0fQW-rQQ3tAAAF, Player: human2, Reason: timesUp, Position: (585, 255)                               
app-1  | [DISCONNECT] Player "human2" with socket Zw6lzr0fQW-rQQ3tAAAF disconnected in room 1766979246579-jdwemtzz8xf.                                              
app-1  | { id: 'hLxAlYSUHVmryFyyAAAD', type: 'snake', avatar: 'snake4' }                                                                                            
app-1  | { id: 'hLxAlYSUHVmryFyyAAAD', type: 'snake', avatar: 'snake4' }                                                                                            
app-1  | [DEBUG]: 1                                                                                                                                                 
app-1  | [DEATH] Game: 1766979246579-jdwemtzz8xf, Socket: hLxAlYSUHVmryFyyAAAD, Player: snake4, Reason: timesUp, Body Positions: (525, 345), (495, 345), (465, 345) 
app-1  | [DISCONNECT] Player "snake4" with socket hLxAlYSUHVmryFyyAAAD disconnected in room 1766979246579-jdwemtzz8xf.

With this approach, the array starts with 3 elements and gets trimmed from the end down to 0. The downside is that the code is a little harder to read.

Why it works

If you iterate from 0 to the end, the array length changes as items are removed, so some elements get skipped. When you start from the last element, delete it, then move to i - 1, the indexes stay valid.

Good

This approach does not need a copy of the array.

Solution 2: iterate over a copy

Create a copy of the data and iterate over that copy while modifying the original array. If you only need IDs, you can copy just the identifiers.

In the example below, this.games[gameId].players holds the player IDs from roomData.players, and we use those IDs to delete items from roomData.

Object.entries(this.games[gameId].players).forEach(([player]) => {
  console.log(...roomData.players);
  console.log(player);
  console.log("[DEBUG]: " + roomData.players.length);
  this.killPlayer(player, DeathReason.TimesUp);
});

Output: similar to solution 1

 { id: '0CqNmgn663afDdaJAAAB', type: 'snake', avatar: 'snake1' } { id: 'Ub-jLHWBB_3mA5uWAAAD', type: 'human', avatar: 'human1' } { id: 'P6tNeqCfyuP_im7SAAAH', type: 'human', avatar: 'human2' }                                                                                                                                
app-1  | 0CqNmgn663afDdaJAAAB
app-1  | [DEBUG]: 3                                                                                                                                                 
app-1  | [DEATH] Game: 1766979497017-8yxtctnvbmx, Socket: 0CqNmgn663afDdaJAAAB, Player: snake1, Reason: timesUp, Body Positions: (675, 465), (675, 495)             
app-1  | [DISCONNECT] Player "snake1" with socket 0CqNmgn663afDdaJAAAB disconnected in room 1766979497017-8yxtctnvbmx.                                              
app-1  | { id: 'Ub-jLHWBB_3mA5uWAAAD', type: 'snake', avatar: 'snake2' } { id: 'P6tNeqCfyuP_im7SAAAH', type: 'human', avatar: 'human2' }                            
app-1  | Ub-jLHWBB_3mA5uWAAAD                                                                                                                                       
app-1  | [DEBUG]: 2                                                                                                                                                 
app-1  | [DEATH] Game: 1766979497017-8yxtctnvbmx, Socket: Ub-jLHWBB_3mA5uWAAAD, Player: human1, Reason: timesUp, Position: (345, 255)                               
app-1  | [DISCONNECT] Player "human1" with socket Ub-jLHWBB_3mA5uWAAAD disconnected in room 1766979497017-8yxtctnvbmx.                                              
app-1  | { id: 'P6tNeqCfyuP_im7SAAAH', type: 'snake', avatar: 'snake6' }                                                                                            
app-1  | P6tNeqCfyuP_im7SAAAH                                                                                                                                       
app-1  | [DEBUG]: 1                                                                                                                                                 
app-1  | [DEATH] Game: 1766979497017-8yxtctnvbmx, Socket: P6tNeqCfyuP_im7SAAAH, Player: human2, Reason: timesUp, Position: (645, 285)                               
app-1  | [DISCONNECT] Player "human2" with socket P6tNeqCfyuP_im7SAAAH disconnected in room 1766979497017-8yxtctnvbmx.     

Good

This is easier to read because you work with a separate copy of the old array data.

Limitation

You need an extra variable, so there’s a bit more handling.

End

This is the end of the post. If you spot any mistakes, feel free to leave feedback through the portfolio footer. I hope this helps. :D