JS
Back to Blog

Cyberpunk: Edgerunners 2 x Cyberpunk 2077's Breach Protocol

Porting the Breach Protocol hacking minigame in anticipation for Season 2 of Edgerunners.

·6 min read·
Cyberpunk 2077
Game Dev
JavaScript
Cyberpunk: Edgerunners 2 x Cyberpunk 2077's Breach Protocol

Edgerunners 2

In my anticipation of Edgerunners 2, I decided to revisit the world of Cyberpunk 2077 and re-create its Breach Protocol logic. This hacking mini-game is just pattern-matching against a timer, with a selection rule that keeps changing the rules on you.

The idea: a grid of two-character hex codes hides a set of "daemons" (short code sequences) inside it. You build a buffer by clicking codes out of the grid, one at a time, and every daemon that appears as a contiguous run in that buffer gets uploaded. The catch is you can't click just any cell. After your first pick, you're locked into either its row or its column, and every pick alternates the axis.

The grid and the hidden path

The grid looks fully random, but it isn't. A solvable path is carved through it first, and only afterward are the path's cells overwritten with fresh random codes. That means the structure of the path survives even though the exact codes on it get randomized again.

The path is built with a randomized backtracking walk that alternates between row-moves and column-moves, mirroring the real selection constraint:

function generateMatrix(size, solutionSize) {
  var grid = [];
  for (var i = 0; i < size * size; i++) grid.push(randomCode());
 
  var direction = DIR.ROW;
  var curRow = randInt(size), curCol = randInt(size);
  var path = [{ row: curRow, col: curCol }];
 
  while (path.length < solutionSize) {
    var candidates = freeCellsInCurrentLine(direction, curRow, curCol, path);
    if (candidates.length === 0) {
      // dead end: pop the path, flip direction back, remember this
      // exact step+direction combo so it isn't retried
      backtrack();
      continue;
    }
    var pick = randomChoice(candidates);
    path.push(pick);
    curRow = pick.row; curCol = pick.col;
    direction = direction === DIR.ROW ? DIR.COL : DIR.ROW;
  }
  return path;
}

Each step only looks at cells in the current row (or column) that haven't already been used and haven't already been tried and abandoned at this exact point in the walk. When a line runs dry, the walk backtracks one step, flips direction back, and marks that exact dead end so it isn't retried. A backtrack budget (1000 steps) keeps this from looping forever on unlucky grids; if the budget runs out, the longest path found so far is used instead of failing outright.

Daemons: picking sub-sequences

Once there's a solved path, daemons are just contiguous slices of it:

function generateSequences(solutionCodes, count, minLen, maxLen) {
  var candidates = [];
  for (var len = minLen; len <= maxLen; len++) {
    for (var start = 0; start + len <= solutionCodes.length; start++) {
      candidates.push(solutionCodes.slice(start, start + len));
    }
  }
  shuffle(candidates);
 
  var picked = [], usedFirstCodes = {};
  for (var i = 0; i < candidates.length && picked.length < count; i++) {
    var cand = candidates[i];
    if (usedFirstCodes[cand[0]]) continue; // avoid ambiguous starts
    usedFirstCodes[cand[0]] = true;
    picked.push({ codes: cand, matchLen: 0, state: 'PENDING' });
  }
  return picked;
}

The one deliberate rule here: no two daemons are allowed to start with the same code. Without it, picking that first shared code would be ambiguous about which daemon you're advancing, which is exactly the kind of "this feels random" moment a puzzle needs to avoid.

The alternating buffer rule

This is the rule that makes the puzzle feel unlike anything else: a cell is only clickable if it matches the current selection axis, and every pick flips the axis for the next one.

var selection = { direction: DIR.ROW, value: 0 };
 
function isCellSelectable(row, col) {
  return selection.direction === DIR.ROW ? row === selection.value : col === selection.value;
}
 
function pickCell(row, col, code) {
  if (!isCellSelectable(row, col)) return false;
  buffer.push(code);
  selection.value = selection.direction === DIR.ROW ? col : row;
  selection.direction = selection.direction === DIR.ROW ? DIR.COL : DIR.ROW;
  return true;
}

Pick a cell in row 2, and the next pick has to come from whatever column that cell was in. Pick from that column, and the pick after has to come from whatever row that cell was in. The lock value isn't fixed in advance, it's read off the cell you just picked. That's the whole mechanic in four lines.

Matching daemons against the buffer

Every time a code lands in the buffer, every still-pending daemon re-checks itself against it:

function evaluateSequences(lastCode) {
  sequences.forEach(function (seq) {
    if (seq.state !== 'PENDING') return;
    var expected = seq.codes[seq.matchLen];
    if (lastCode === expected) {
      seq.matchLen++;
      if (seq.matchLen === seq.codes.length) seq.state = 'SOLVED';
    } else {
      // mismatch: restart, unless this code happens to be the
      // sequence's own first code, then start matching from there
      seq.matchLen = (lastCode === seq.codes[0]) ? 1 : 0;
    }
    var remainingNeeded = seq.codes.length - seq.matchLen;
    var remainingCapacity = bufferSize - buffer.length;
    if (seq.state === 'PENDING' && remainingNeeded > remainingCapacity) {
      seq.state = 'FAILED';
    }
  });
}

This is a contiguous-run match, not a loose subsequence check. One wrong code and progress resets, it doesn't just pause. The one exception: if the code that broke your streak happens to be the daemon's own first code, matching restarts from position one instead of zero, since that code could still be the start of a fresh attempt. A daemon that can no longer mathematically fit in the remaining buffer space gets marked failed immediately rather than waiting for the buffer to fill.

Why this one didn't need a canvas

Breach Protocol has nothing too dynamic: a static grid of text, a row of buffer slots, and a handful of code chips. No rotation, no curves, no particles, at least, not until I add juice.

So this one is plain document.createElement and CSS display: grid, with the timer driven by a 100ms setInterval instead of a render loop. It's the same overall shape as the other demos just without dragging in a renderer that has nothing to render.

Play it

Click to play

What I added beyond the original

The original is a single timed run with a difficulty dial. This version is endless: six level tiers scale grid size, buffer size, and daemon count, and a win advances to the next tier while a failure regenerates the same tier instead of demoting. Score and a running "cracked" counter lerp toward their targets each tick the same way the other two demos animate their HUDs. Each board also gets a displayed puzzle ID from a seeded PRNG, mostly because it looks right sitting next to a countdown timer.