Pathgen LogoPathgen Blog
Deep Dive 25 min readChapter 7 Season 2 · FileVersion 7

Reverse Engineering the Fortnite .replay Binary Format

This is the guide we wish existed when we started. Everything we learned after months of binary analysis, trial and error, and 33 confirmed assertion checks against real match data. If you want to parse Fortnite replay files from scratch, start here.

Fortnite replay files contain a complete serialized record of every network packet your game client received during a match. They are not video files. They are a time-ordered binary stream of the entire multiplayer game state, encrypted and compressed, using Unreal Engine's proprietary networking layer. Parsing them correctly gives you every stat, every position, every weapon interaction, and every elimination — directly from the source, without relying on Epic's public APIs.

2. Chunk Structure

After the header, the rest of the file is a sequence of chunks. Each chunk has a fixed 16-byte header followed by its payload. The chunk types you care about are type 1 (ReplayData) and type 3 (Event). Type 2 (Checkpoint) contains full world state snapshots used for fast-seeking and is useful for jump-starting position tracking mid-match.

// Chunk header — appears before every chunk in the file
struct ChunkHeader {
  uint32 ChunkType;   // 0=Header 1=ReplayData 2=Checkpoint 3=Event
  int32  SizeInBytes; // size of the chunk data that follows
  int32  LengthInMs;  // timestamp / duration
  uint32 Flags;
}

// After ChunkHeader, the raw chunk bytes follow immediately.
// For type 1 (ReplayData) and type 2 (Checkpoint):
//   - If bEncrypted: decrypt with AES-256-ECB first
//   - If bCompressed: decompress with Oodle after decrypt

In our testing against Chapter 7 Season 2 replays, a typical 20-minute match produces 65 decryptable chunks. The chunk size varies from a few hundred bytes for sparse frames up to several megabytes for high-action sequences with many player updates.

3. AES-256-ECB Decryption

This is where most parsers get it wrong. Fortnite replay data chunks use AES-256-ECB mode — Electronic Codebook — with no initialization vector. There is no IV, no salt, no key derivation. The raw 32-byte key from the header goes directly into the cipher.

PKCS7 padding is also not applied. Set setAutoPadding(false) in Node.js or the equivalent in your language. If you leave auto-padding enabled you will get garbage at the end of every decrypted chunk.

import crypto from 'crypto';

function decryptChunk(encryptedBytes, keyBuffer) {
  // AES-256-ECB — NO initialization vector
  // Key is the 32-byte raw key from the replay header
  const decipher = crypto.createDecipheriv(
    'aes-256-ecb',
    keyBuffer,
    null   // no IV for ECB mode
  );
  decipher.setAutoPadding(false); // PKCS7 padding is not used
  return Buffer.concat([
    decipher.update(encryptedBytes),
    decipher.final()
  ]);
}

// Key extraction from header:
// After parsing FriendlyName and the three booleans,
// read exactly 32 bytes — that is your AES key.
// No hashing, no derivation. Raw bytes directly.
const keyOffset = findKeyOffset(headerBuffer);
const aesKey = headerBuffer.slice(keyOffset, keyOffset + 32);

ECB mode is considered cryptographically weak for general use because identical plaintext blocks produce identical ciphertext blocks. Epic uses it here for performance reasons — AES-ECB is the fastest symmetric mode and they are decrypting this in real-time during spectating. For our purposes it is ideal because it means chunks can be decrypted independently and in parallel.

4. Oodle Decompression

After decryption, if bCompressedis true in the header, the decrypted payload is Oodle-compressed. Oodle is Epic's proprietary compression library used across all Unreal Engine titles. The first 4 bytes of the decrypted payload are the uncompressed size as a little-endian uint32.

// Oodle decompression — required after AES decrypt
// Epic uses Oodle internally. The npm package ooz-wasm
// wraps the reference implementation.

import OozWasm from 'ooz-wasm';

const ooz = await OozWasm.create();

function decompressChunk(compressedBytes) {
  // First 4 bytes of the decrypted payload are the
  // uncompressed size as a little-endian uint32
  const uncompressedSize = compressedBytes.readUInt32LE(0);
  const compressedData   = compressedBytes.slice(4);

  const output = new Uint8Array(uncompressedSize);
  const result = ooz.decompress(compressedData, output);

  if (result !== uncompressedSize) {
    throw new Error(`Oodle: expected ${uncompressedSize} got ${result}`);
  }
  return Buffer.from(output);
}

The ooz-wasm package on npm provides a WebAssembly port of the Oodle reference decompressor. Install it with npm install ooz-wasm. Decompression is fast — a 2MB compressed chunk typically decompresses in under 50ms. The bulk of parse time is in the bitstream walking phase that follows.

5. NetBitStream — Reading Property Updates

Once you have the decompressed bytes, you are looking at Unreal Engine's NetBitStream format. This is not byte-aligned. Properties are packed at the bit level to minimize network bandwidth. You need a proper bitstream reader that can read arbitrary numbers of bits and advance the cursor at sub-byte granularity.

The key encoding to understand is IntPacked — Unreal's variable-length integer encoding. It uses 6 bits of data and 2 control bits per byte: one bit for sign and one for continuation. Most stat values — kills, damage, materials — are stored as IntPacked.

// After decryption and decompression you have a raw
// NetBitStream — Unreal Engine's custom bit-packing format.
// Properties are encoded per channel (actor) with handle IDs.

// Reading an IntPacked value (variable-length encoding):
function readIntPacked(bitStream) {
  let value = 0;
  let shift = 0;
  while (true) {
    const byte = bitStream.readBits(8);
    value |= (byte & 0x3F) << shift;
    shift += 6;
    if (!(byte & 0x80)) break; // continuation bit
  }
  return value;
}

// Each property update on a channel looks like:
// [handle: IntPacked] [value: type-specific encoding]
// Handle 0 = null terminator for that actor's update

Some properties use fixed-width bit fields instead. Stone gathered, for example, uses Bits(11) — exactly 11 bits, supporting values up to 2047. Distance traveled uses Bits(32). The encoding type is determined by the handle ID and confirmed by cross-referencing against known values from real matches.

6. Confirmed Stat Handles — Chapter 7 Season 2

This is the section that took the longest to produce. Handle IDs are not documented anywhere by Epic. Every handle below was confirmed by parsing real match replays and cross-referencing extracted values against known ground truth — the stats shown in the Fortnite post-game screen.

The player stat channel (FortPlayerStateAthena) is dynamic — its channel number changes per match. You cannot hardcode a channel index. Instead, find it by scanning all channels for the one where handle 125 matches the kill count shown in the post-game screen for the local player.

// Confirmed FortPlayerStateAthena handles — Ch7 S2
// Channel is dynamic — identified by finding the channel
// where handle 125 (kills) matches AthenaMatchStats

const PLAYER_HANDLES = {
  1:   'shotsFired',      // IntPacked
  2:   'wood',            // IntPacked
  3:   'buildsPlaced',    // IntPacked
  4:   'stone',           // Bits(11)
  5:   'metal',           // IntPacked
  6:   'shotsHit',        // IntPacked
  16:  'shieldHealed',    // IntPacked
  22:  'healthHealed',    // IntPacked
  100: 'buildsEdited',    // IntPacked
  113: 'damageTaken',     // IntPacked
  114: 'damageDealt',     // IntPacked
  120: 'stormDamage',     // IntPacked
  125: 'kills',           // IntPacked ← use this to find the channel
  126: 'headshots',       // IntPacked
};

// AFortWeapon channel handles — per weapon equipped
const WEAPON_HANDLES = {
  11: 'shots',            // IntPacked
  13: 'hitsToPlayers',    // IntPacked
  15: 'hitsToPlayers2',   // IntPacked (some weapon types)
  21: 'damageToPlayers',  // IntPacked
  26: 'hitsToAI',         // IntPacked ← confirmed handle 26
};

// AFortPlayerPawn handle
// 65: distanceCm — Bits(32) — total distance traveled on foot

Important caveat: handle 4 (stone) uses Bits(11)while handles 2 (wood) and 5 (metal) use IntPacked despite being the same data type logically. This inconsistency is internal to Epic's serialization and must be handled case-by-case.

The weapon channel (AFortWeapon) follows a similar pattern — each weapon the player holds gets its own channel. Handle 26 for hits-to-AI was confirmed by comparing the Chaos Reloader Shotgun (which hit one AI bot) against the Combat AR (which did not) in the same match.

7. Position Extraction and Speed Filtering

Player positions come from the AFortPlayerPawn actor channel as location property updates. Each position update is three signed 16-bit integers — X, Y, Z. Multiply each by 2 to get centimeters in Fortnite world space.

Raw position data contains artifacts. Interpolation, teleportation for respawns, and network corrections can produce jumps that are not real movement. Apply a speed filter: any position update that implies a speed above 8000 cm/s (80 m/s — well above the maximum skydive speed) should be discarded.

// Position extraction from AFortPlayerPawn
// Positions are stored as int16 pairs, scaled to world coords

function decodePosition(bitStream) {
  // Each coordinate is 16 bits signed
  const rawX = bitStream.readInt16();
  const rawY = bitStream.readInt16();
  const rawZ = bitStream.readInt16();

  // Scale factor: multiply by 2 to get centimeters
  return {
    x: rawX * 2,
    y: rawY * 2,
    z: rawZ * 2,
  };
}

// Speed filter — reject teleports and interpolation artifacts
const MAX_SPEED_CM_PER_S = 8000;

function isValidPosition(prev, curr, deltaMs) {
  const dx = curr.x - prev.x;
  const dy = curr.y - prev.y;
  const dist = Math.sqrt(dx*dx + dy*dy);
  const speed = dist / (deltaMs / 1000);
  return speed < MAX_SPEED_CM_PER_S;
}

// World bounds for Chapter 7 Season 2
const WORLD_MIN = -131072; // cm
const WORLD_MAX =  131072; // cm

In a typical 20-minute match we extract 174,487 raw position readings across all 92 players. After speed filtering and sampling to one position per 2.5 seconds, this reduces to approximately 450 points for the local player track — enough resolution for smooth path visualization without being wasteful.

The Fortnite island sits within world bounds of ±131,072 cm (±1.31 km). Any position outside these bounds is the player skydiving before landing. Track positions within bounds separately from skydive positions to calculate foot distance and air distance independently.

8. Player Name Deobfuscation

Player display names in the replay are not stored plaintext. They are obfuscated with a simple character shift. Each character is shifted by (3 + i * 3) % 8 positions where i is the character index, operating on the printable ASCII range (32–126).

// Player name deobfuscation
// Names in the replay are XOR-shifted by character index

function deobfuscateName(obfuscated) {
  return obfuscated
    .split('')
    .map((char, i) => {
      const shift = (3 + i * 3) % 8;
      const code  = char.charCodeAt(0);
      return String.fromCharCode(
        code >= 32 && code < 127
          ? ((code - 32 + shift) % 95) + 32
          : code
      );
    })
    .join('');
}

// Channel detection for local player:
// The local player's channel will have the account ID
// embedded in an FString property early in the stream.
// Cross-reference with AthenaMatchStats to confirm.

This is not encryption — it is obfuscation to prevent casual inspection of the binary file. The algorithm is consistent across all Chapter 7 Season 2 replays we tested. Names of 92 players in a full lobby are decoded correctly using this shift formula.

9. Storm Circle Decoding

Storm data comes from the SafeZoneIndicatoractor. Watch for property updates on this actor's channel — specifically float values representing the current and next circle radii, and a vector value for the next circle center position.

// Storm circle data — decoded from ReplayData chunks
// Phase radii come from the SafeZoneIndicator actor

// Chapter 7 Season 2 — 12 phases confirmed
const STORM_DPS = [1, 1, 1, 2, 2, 3, 4, 5, 5, 7, 7, 8];

// Storm positions are world coordinates (same scale as players)
// Each phase has:
//   - currentRadius: shrinking zone radius in cm
//   - nextRadius:    target radius after shrink completes  
//   - center:        (x, y) world coords of next circle center
//   - shrinkStartMs: timestamp when shrink begins
//   - shrinkEndMs:   timestamp when shrink completes

// Detected by watching the SafeZoneIndicator channel
// for property updates matching float radius values

Chapter 7 Season 2 has 12 storm phases with DPS values increasing from 1 at phase 1 to 8 at phase 12. The starting safe zone radius is approximately 105,723 cm (about 1.06 km radius). By the final circle this shrinks to a few hundred centimeters — effectively a single building.

10. Channel Detection — Finding the Right Actor

The single hardest part of replay parsing is channel identification. Unreal Engine assigns channel numbers dynamically per match — there is no fixed mapping of channel 5 = local player. You must infer actor types from context.

The approach that works reliably: collect all stat values on all channels. Cross-reference against known ground truth — the post-game screen shows kills, damage, and materials for the local player. Find the channel where the extracted values match. From that anchor channel you can identify nearby channels by actor class name strings that appear early in each channel's stream.

For weapon channels specifically: each weapon the player equips gets a new channel. Track all channels that have handle 21 (damageToPlayers) updates. Correlate with equip/unequip events to assign weapon names to channels. The weapon name comes from an FString property on the weapon channel early in its lifecycle.

Validated Results — Game2.replay

After implementing all of the above, our parser produces the following against a real Chapter 7 Season 2 Victory Royale replay — all 33 assertions passing:

ResultVictory Royale
Placement1st / 98 players
Human players28 (70 AI bots)
Kills4 player + 2 AI
Damage dealt1,108
Damage taken398
Accuracy21.3%
Headshots4 (12.5%)
Builds placed327
Builds edited74
Materials gathered5,165 total
Distance on foot4.6 km
Time in storm66,000ms
Parse time842ms
Positions extracted174,487
Players decoded92 / 92

Skip the 6 months of reverse engineering

The Pathgen API does all of this in 842ms. Upload a .replay file, get structured JSON back with 33 confirmed fields, per-weapon stats, full scoreboard, positions, storm data, and AI coaching. $0.02 per parse.

Build on Pathgen

The Fortnite data infrastructure Osirion wishes it had. 33 confirmed fields. 842ms parse time. AI coaching. FNCS session analysis. $0.02 per replay.

© 2026 Pathgen. Built by developers who actually parsed the binary.