diff --git a/library/Octane.hs b/library/Octane.hs
--- a/library/Octane.hs
+++ b/library/Octane.hs
@@ -1,5 +1,6 @@
 module Octane
     ( module Octane.Data
+    , module Octane.FullReplay
     , module Octane.Main
     , module Octane.Parser
     , module Octane.Type
@@ -7,6 +8,7 @@
     ) where
 
 import Octane.Data
+import Octane.FullReplay
 import Octane.Main
 import Octane.Parser
 import Octane.Type
diff --git a/library/Octane/Data.hs b/library/Octane/Data.hs
--- a/library/Octane/Data.hs
+++ b/library/Octane/Data.hs
@@ -1052,17 +1052,17 @@
     , ("Washington Wizards", 1275)
     -- Video Games
     , ("Blacklight", 385)
-    , ("Blacklight: Retribution", todo)
-    , ("Chivalry - Agatha Knights", todo)
-    , ("Chivalry - Mason Order", todo)
-    , ("Edge Of Space", todo)
-    , ("Euro Truck Simulator Rig", todo)
-    , ("Fallout - Vault Boy", todo)
-    , ("Fenix Rage", todo)
-    , ("Goat Simulator - G2", todo)
-    , ("Goat Simulator - Goatenna", todo)
-    , ("Oddworld - Abe", todo)
-    , ("Oddworld - Molluck", todo)
+    , ("Blacklight: Retribution", 211)
+    , ("Chivalry - Agatha Knights", 525)
+    , ("Chivalry - Mason Order", 526)
+    , ("Edge Of Space", 213)
+    , ("Euro Truck Simulator Rig", 813)
+    , ("Fallout - Vault Boy", 673)
+    , ("Fenix Rage", 216)
+    , ("Goat Simulator - G2", 927)
+    , ("Goat Simulator - Goatenna", 867)
+    , ("Oddworld - Abe", 678)
+    , ("Oddworld - Molluck", 679)
     , ("Oddworld - Necrum", todo)
     , ("Oddworld - RuptureFarms", todo)
     , ("Portal - Aperture Laboratories", todo)
diff --git a/library/Octane/FullReplay.hs b/library/Octane/FullReplay.hs
new file mode 100644
--- /dev/null
+++ b/library/Octane/FullReplay.hs
@@ -0,0 +1,453 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Octane.FullReplay
+    ( FullReplay
+    , parseReplay
+    , parseReplayFile
+    , unsafeParseReplay
+    , unsafeParseReplayFile
+    ) where
+
+import Data.Aeson ((.=))
+import Data.Function ((&))
+import Data.Monoid ((<>))
+import Prelude ((==), (/=), (&&))
+
+import qualified Control.DeepSeq as DeepSeq
+import qualified Control.Monad as Monad
+import qualified Data.Aeson as Aeson
+import qualified Data.Binary as Binary
+import qualified Data.ByteString.Lazy as ByteString
+import qualified Data.Foldable as Foldable
+import qualified Data.IntMap.Strict as IntMap
+import qualified Data.Map.Strict as Map
+import qualified Data.Text as Text
+import qualified Data.Version as Version
+import qualified GHC.Generics as Generics
+import qualified Octane.Parser as Parser
+import qualified Octane.Type as Type
+import qualified Prelude
+
+
+newtype FullReplay = FullReplay
+    { unpackFullReplay :: (Type.Replay, [Parser.Frame])
+    } deriving (Generics.Generic, Prelude.Show)
+
+instance DeepSeq.NFData FullReplay
+
+instance Aeson.ToJSON FullReplay where
+    toJSON fullReplay = do
+        Aeson.object
+            [ "Version" .= getVersion fullReplay
+            , "Metadata" .= getMetadata fullReplay
+            , "Levels" .= getLevels fullReplay
+            , "Messages" .= getMessages fullReplay
+            , "TickMarks" .= getTickMarks fullReplay
+            , "Frames" .= getFrames fullReplay
+            ]
+
+
+newFullReplay :: Type.Replay -> [Parser.Frame] -> FullReplay
+newFullReplay replay frames = FullReplay (replay, frames)
+
+
+getVersion :: FullReplay -> Prelude.String
+getVersion fullReplay =
+    [ fullReplay
+        & unpackFullReplay
+        & Prelude.fst
+        & Type.replayVersion1
+        & Type.unpackWord32LE
+        & Prelude.fromIntegral
+    , fullReplay
+        & unpackFullReplay
+        & Prelude.fst
+        & Type.replayVersion2
+        & Type.unpackWord32LE
+        & Prelude.fromIntegral
+    ] & Version.makeVersion & Version.showVersion
+
+
+getMetadata :: FullReplay -> Map.Map Text.Text Aeson.Value
+getMetadata fullReplay = fullReplay
+    & unpackFullReplay
+    & Prelude.fst
+    & Type.replayProperties
+    & Type.unpackDictionary
+    & Map.mapKeys Type.unpackPCString
+    & Map.map Aeson.toJSON
+
+
+getLevels :: FullReplay -> [Text.Text]
+getLevels fullReplay = fullReplay
+    & unpackFullReplay
+    & Prelude.fst
+    & Type.replayLevels
+    & Type.unpackList
+    & Prelude.map Type.unpackPCString
+
+
+getMessages :: FullReplay -> Map.Map Text.Text Type.PCString
+getMessages fullReplay = fullReplay
+    & unpackFullReplay
+    & Prelude.fst
+    & Type.replayMessages
+    & Type.unpackList
+    & Prelude.map (\ message ->
+        ( message
+            & Type.messageFrame
+            & Type.unpackWord32LE
+            & Prelude.show
+            & Text.pack
+        , message & Type.messageContent
+        ))
+    & Map.fromList
+
+
+getTickMarks :: FullReplay -> Map.Map Text.Text Type.PCString
+getTickMarks fullReplay = fullReplay
+    & unpackFullReplay
+    & Prelude.fst
+    & Type.replayMarks
+    & Type.unpackList
+    & Prelude.map (\ mark ->
+        ( mark
+            & Type.markFrame
+            & Type.unpackWord32LE
+            & Prelude.show
+            & Text.pack
+        , mark & Type.markLabel
+        ))
+    & Map.fromList
+
+
+getFrames :: FullReplay -> [Map.Map Text.Text Aeson.Value]
+getFrames fullReplay = fullReplay
+    & unpackFullReplay
+    & Prelude.snd
+    & Foldable.foldl'
+        (\ (state, frames) frame -> let
+            newState = updateState frame state
+            minimalFrame = getDelta state frame
+            in (newState, minimalFrame : frames))
+        (initialState, [])
+    & Prelude.snd
+    & Prelude.reverse
+    & Prelude.map (\ frame -> Map.fromList
+        [ ("IsKeyFrame", frame & Parser.frameIsKeyFrame & Aeson.toJSON)
+        , ("Number", frame & Parser.frameNumber & Aeson.toJSON)
+        , ("Time", frame & Parser.frameTime & Aeson.toJSON)
+        , ("Delta", frame & Parser.frameDelta & Aeson.toJSON)
+        , ("Spawned", getSpawnedActors frame)
+        , ("Updated", getUpdatedActors frame)
+        , ("Destroyed", getDestroyedActors frame)
+        ])
+
+
+-- { actor id => (alive?, { property name => property value } ) }
+type State = IntMap.IntMap (Prelude.Bool, Map.Map Text.Text Parser.PropValue)
+
+
+initialState :: State
+initialState = IntMap.empty
+
+
+updateState :: Parser.Frame -> State -> State
+updateState frame state1 = let
+    spawned = frame
+        & Parser.frameReplications
+        & Prelude.filter (\ replication -> replication
+            & Parser.replicationState
+            & (== Parser.RSOpening))
+        & Prelude.map Parser.replicationActorId
+    state2 = spawned
+        & Prelude.foldr
+            (IntMap.alter (\ maybeValue -> Prelude.Just (case maybeValue of
+                Prelude.Nothing -> (Prelude.True, Map.empty)
+                Prelude.Just (_, properties) -> (Prelude.True, properties))))
+            state1
+
+    destroyed = frame
+        & Parser.frameReplications
+        & Prelude.filter (\ replication -> replication
+            & Parser.replicationState
+            & (== Parser.RSClosing))
+        & Prelude.map Parser.replicationActorId
+    state3 = destroyed
+        & Prelude.foldr
+            (IntMap.alter (\ maybeValue -> Prelude.Just (case maybeValue of
+                Prelude.Nothing -> (Prelude.False, Map.empty)
+                Prelude.Just (_, properties) -> (Prelude.False, properties))))
+            state2
+
+    updated = frame
+        & Parser.frameReplications
+        & Prelude.filter (\ replication -> replication
+            & Parser.replicationState
+            & (== Parser.RSExisting))
+    state4 = updated
+        & Prelude.foldr
+            (\ replication -> IntMap.alter
+                (\ maybeValue -> Prelude.Just (case maybeValue of
+                    Prelude.Nothing ->
+                        (Prelude.True, Parser.replicationProperties replication)
+                    Prelude.Just (alive, properties) ->
+                        ( alive
+                        , Map.union
+                            (Parser.replicationProperties replication)
+                            properties
+                        )))
+                (Parser.replicationActorId replication))
+            state3
+
+    in state4
+
+
+getDelta :: State -> Parser.Frame -> Parser.Frame
+getDelta state frame = let
+    newReplications = frame
+        & Parser.frameReplications
+        -- Remove replications that aren't actually new.
+        & reject (\ replication -> let
+            isOpening = Parser.replicationState replication == Parser.RSOpening
+            actorId = Parser.replicationActorId replication
+            currentState = IntMap.lookup actorId state
+            isAlive = Prelude.fmap Prelude.fst currentState
+            wasAlreadyAlive = isAlive == Prelude.Just Prelude.True
+            in isOpening && wasAlreadyAlive)
+        -- Remove properties that haven't changed.
+        & Prelude.map (\ replication ->
+            if Parser.replicationState replication == Parser.RSExisting
+            then let
+                actorId = Parser.replicationActorId replication
+                currentState = IntMap.findWithDefault
+                    (Prelude.True, Map.empty) actorId state
+                currentProperties = Prelude.snd currentState
+                newProperties = Parser.replicationProperties replication
+                changes = newProperties
+                    & Map.filterWithKey (\ name newValue -> let
+                        oldValue = Map.lookup name currentProperties
+                        in Prelude.Just newValue /= oldValue)
+                in replication { Parser.replicationProperties = changes }
+            else replication)
+    in frame { Parser.frameReplications = newReplications }
+
+
+reject :: (a -> Prelude.Bool) -> [a] -> [a]
+reject p xs = Prelude.filter (\ x -> Prelude.not (p x)) xs
+
+
+getSpawnedActors :: Parser.Frame -> Aeson.Value
+getSpawnedActors frame = frame
+    & Parser.frameReplications
+    & Prelude.filter (\ replication -> replication
+        & Parser.replicationState
+        & (== Parser.RSOpening))
+    & Prelude.map (\ replication ->
+        ( replication
+            & Parser.replicationActorId
+            & Prelude.show
+            & Text.pack
+        , Aeson.object
+            [ ("Name", replication
+                & Parser.replicationObjectName
+                & Aeson.toJSON)
+            , ("Class", replication
+                & Parser.replicationClassName
+                & Aeson.toJSON)
+            , ("Position", replication
+                & Parser.replicationInitialization
+                & Prelude.fmap Parser.classInitLocation
+                & Monad.join
+                & Aeson.toJSON)
+            , ("Rotation", replication
+                & Parser.replicationInitialization
+                & Prelude.fmap Parser.classInitRotation
+                & Monad.join
+                & Aeson.toJSON)
+            ]
+        ))
+    & Aeson.object
+
+
+getUpdatedActors :: Parser.Frame -> Aeson.Value
+getUpdatedActors frame = frame
+    & Parser.frameReplications
+    & Prelude.filter (\ replication -> replication
+        & Parser.replicationState
+        & (== Parser.RSExisting))
+    & Prelude.map (\ replication ->
+        ( replication
+            & Parser.replicationActorId
+            & Prelude.show
+            & Text.pack
+        , replication
+            & Parser.replicationProperties
+            & Map.map (\ property -> Aeson.object
+                [ ("Type", getPropertyName property)
+                , ("Value", getPropertyValue property)
+                ])
+        ))
+    & reject (\ (_, properties) -> Map.null properties)
+    & Prelude.map (\ (actorId, properties) ->
+        (actorId, Aeson.toJSON properties))
+    & Aeson.object
+
+
+getPropertyName :: Parser.PropValue -> Aeson.Value
+getPropertyName property = case property of
+    Parser.PBoolean _ -> "Boolean"
+    Parser.PByte _ -> "Byte"
+    Parser.PCamSettings _ _ _ _ _ _ -> "CameraSettings"
+    Parser.PDemolish _ _ _ _ _ _ -> "Demolition"
+    Parser.PEnum _ _ -> "Enum"
+    Parser.PExplosion _ _ _ -> "Explosion"
+    Parser.PFlaggedInt _ _ -> "FlaggedInt"
+    Parser.PFloat _ -> "Float"
+    Parser.PGameMode _ -> "GameMode"
+    Parser.PInt _ -> "Int"
+    Parser.PLoadout _ _ _ _ _ _ _ _ _ -> "Loadout"
+    Parser.PLoadoutOnline _ _ _ _ -> "OnlineLoadout"
+    Parser.PLocation _ -> "Position"
+    Parser.PMusicStinger _ _ _ -> "MusicStinger"
+    Parser.PPickup _ _ _ -> "Pickup"
+    Parser.PPrivateMatchSettings _ _ _ _ _ _ -> "PrivateMatchSettings"
+    Parser.PQWord _ _ -> "QWord"
+    Parser.PRelativeRotation _ -> "RelativeRotation"
+    Parser.PReservation _ _ _ _ _ _ _ -> "Reservation"
+    Parser.PRigidBodyState _ _ _ _ _ -> "RigidBodyState"
+    Parser.PString _ -> "String"
+    Parser.PTeamPaint _ _ _ _ _ -> "Paint"
+    Parser.PUniqueId _ _ _ -> "UniqueId"
+
+
+getPropertyValue :: Parser.PropValue -> Aeson.Value
+getPropertyValue property = case property of
+    Parser.PBoolean x -> Aeson.toJSON x
+    Parser.PByte x -> Aeson.toJSON x
+    Parser.PCamSettings fov height angle distance stiffness swivelSpeed -> Aeson.object
+        [ ("FOV", Aeson.toJSON fov)
+        , ("Height", Aeson.toJSON height)
+        , ("Angle", Aeson.toJSON angle)
+        , ("Distance", Aeson.toJSON distance)
+        , ("Stiffness", Aeson.toJSON stiffness)
+        , ("SwivelSpeed", Aeson.toJSON swivelSpeed)
+        ]
+    Parser.PDemolish a b c d e f -> Aeson.toJSON (a, b, c, d, e, f)
+    Parser.PEnum x y -> Aeson.toJSON (x, y)
+    Parser.PExplosion a b c -> Aeson.toJSON (a, b, c)
+    Parser.PFlaggedInt x y -> Aeson.toJSON (x, y)
+    Parser.PFloat x -> Aeson.toJSON x
+    Parser.PGameMode x -> case x of
+        1 -> "Hockey"
+        2 -> "Hoops"
+        _ -> Aeson.String ("Unknown game mode " <> Text.pack (Prelude.show x))
+    Parser.PInt x -> Aeson.toJSON x
+    Parser.PLoadout version body decal wheels rocketTrail antenna topper x y -> Aeson.object
+        [ ("Version", Aeson.toJSON version)
+        , ("Body", Aeson.toJSON body)
+        , ("Decal", Aeson.toJSON decal)
+        , ("Wheels", Aeson.toJSON wheels)
+        , ("RocketTrail", Aeson.toJSON rocketTrail)
+        , ("Antenna", Aeson.toJSON antenna)
+        , ("Topper", Aeson.toJSON topper)
+        , ("Unknown1", Aeson.toJSON x)
+        , ("Unknown2", Aeson.toJSON y)
+        ]
+    Parser.PLoadoutOnline a b c d -> Aeson.toJSON (a, b, c, d)
+    Parser.PLocation x -> Aeson.toJSON x
+    Parser.PMusicStinger a b c -> Aeson.toJSON (a, b, c)
+    Parser.PPickup a b c -> Aeson.toJSON (a, b, c)
+    Parser.PPrivateMatchSettings mutators joinableBy maxPlayers name password x -> Aeson.object
+        [ ("Mutators", Aeson.toJSON mutators)
+        , ("JoinableBy", Aeson.toJSON joinableBy)
+        , ("MaxPlayers", Aeson.toJSON maxPlayers)
+        , ("Name", Aeson.toJSON name)
+        , ("Password", Aeson.toJSON password)
+        , ("Unknown", Aeson.toJSON x)
+        ]
+    Parser.PQWord a b -> Aeson.toJSON (a, b)
+    Parser.PRelativeRotation x -> Aeson.toJSON x
+    Parser.PReservation number systemId remoteId localId name x y -> Aeson.object
+        [ ("Number", Aeson.toJSON number)
+        , ("SystemId", Aeson.toJSON systemId)
+        , ("RemoteId", Aeson.toJSON remoteId)
+        , ("LocalId", Aeson.toJSON localId)
+        , ("Name", Aeson.toJSON name)
+        , ("Unknown1", Aeson.toJSON x)
+        , ("Unknown2", Aeson.toJSON y)
+        ]
+    Parser.PRigidBodyState sleeping position rotation linear angular -> Aeson.object
+        [ ("Sleeping", Aeson.toJSON sleeping)
+        , ("Position", Aeson.toJSON position)
+        , ("Rotation", Aeson.toJSON rotation)
+        , ("LinearVelocity", Aeson.toJSON linear)
+        , ("AngularVelocity", Aeson.toJSON angular)
+        ]
+    Parser.PString x -> Aeson.toJSON x
+    Parser.PTeamPaint team color1 color2 finish1 finish2 -> Aeson.object
+        [ ("Team", Aeson.toJSON team)
+        , ("PrimaryColor", Aeson.toJSON color1)
+        , ("AccentColor", Aeson.toJSON color2)
+        , ("PrimaryFinish", Aeson.toJSON finish1)
+        , ("AccentFinish", Aeson.toJSON finish2)
+        ]
+    Parser.PUniqueId systemId remoteId localId -> Aeson.object
+        [ ("System", case systemId of
+            0 -> "Local"
+            1 -> "Steam"
+            2 -> "PlayStation"
+            4 -> "Xbox"
+            _ -> Aeson.String ("Unknown system " <> Text.pack (Prelude.show systemId)))
+        , ("Remote", case remoteId of
+            Parser.SplitscreenId x -> Aeson.toJSON x
+            Parser.SteamId x -> Aeson.toJSON x
+            Parser.PlayStationId x -> Aeson.toJSON x
+            Parser.XboxId x -> Aeson.toJSON x)
+        , ("Local", Aeson.toJSON localId)
+        ]
+
+getDestroyedActors :: Parser.Frame -> Aeson.Value
+getDestroyedActors frame = frame
+    & Parser.frameReplications
+    & Prelude.filter (\ replication -> replication
+        & Parser.replicationState
+        & (== Parser.RSClosing))
+    & Prelude.map Parser.replicationActorId
+    & Aeson.toJSON
+
+
+parseReplay :: ByteString.ByteString -> Prelude.Either Text.Text FullReplay
+parseReplay bytes = do
+    case Binary.decodeOrFail bytes of
+        Prelude.Left (_, _, message) -> do
+            Prelude.Left (Text.pack message)
+        Prelude.Right (_, _, replay) -> do
+            let frames = Parser.parseFrames replay
+            Prelude.Right (newFullReplay replay frames)
+
+
+parseReplayFile :: Prelude.FilePath -> Prelude.IO (Prelude.Either Text.Text FullReplay)
+parseReplayFile file = do
+    result <- Binary.decodeFileOrFail file
+    case result of
+        Prelude.Left (_, message) -> do
+            Prelude.pure (Prelude.Left (Text.pack message))
+        Prelude.Right replay -> do
+            let frames = Parser.parseFrames replay
+            Prelude.pure (Prelude.Right (newFullReplay replay frames))
+
+
+unsafeParseReplay :: ByteString.ByteString -> FullReplay
+unsafeParseReplay bytes = do
+    let replay = Binary.decode bytes
+    let frames = Parser.parseFrames replay
+    newFullReplay replay frames
+
+
+unsafeParseReplayFile :: Prelude.FilePath -> Prelude.IO FullReplay
+unsafeParseReplayFile file = do
+    replay <- Binary.decodeFile file
+    let frames = Parser.parseFrames replay
+    Prelude.pure (newFullReplay replay frames)
diff --git a/library/Octane/Main.hs b/library/Octane/Main.hs
--- a/library/Octane/Main.hs
+++ b/library/Octane/Main.hs
@@ -1,75 +1,16 @@
 module Octane.Main (main) where
 
 import qualified Control.DeepSeq as DeepSeq
-import qualified Control.Monad as Monad
 import qualified Data.Aeson as Aeson
-import qualified Data.Binary as Binary
-import qualified Data.Binary.Get as Binary
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Lazy as BSL
-import qualified Data.ByteString.Lazy.Char8 as BSL8
-import Data.Function ((&))
-import qualified Data.Map as Map
-import qualified Data.Maybe as Maybe
-import qualified Data.Text as Text
-import qualified Octane.Parser as Parser
-import qualified Octane.Type as Type
+import qualified Data.ByteString.Lazy.Char8 as ByteString
+import qualified Octane.FullReplay as FullReplay
+import qualified Prelude
 import qualified System.Environment as Environment
-import qualified System.IO as IO
 
-main :: IO ()
-main = do
-    files <- Environment.getArgs
-    contents <- mapM BS.readFile files
-    results <- mapM Binary.decodeFileOrFail files
-    mapM_ debug (zip3 files contents results)
 
-debug :: (String, BS.ByteString, Either (Binary.ByteOffset, String) Type.Replay)
-      -> IO ()
-debug (file,contents,result) =
-    case result of
-        Left (offset,message) -> error
-            ( file
-            ++ " @ byte "
-            ++ show offset
-            ++ ": "
-            ++ message
-            )
-        Right replay -> do
-            let inputSize = contents & BS.length & fromIntegral
-            let outputSize = replay
-                    & Binary.encode
-                    & BSL.length
-                    & DeepSeq.deepseq replay
-            Monad.when (inputSize /= outputSize) (IO.hPutStrLn IO.stderr
-                ( "input size "
-                ++ show inputSize
-                ++ " not equal to output size "
-                ++ show outputSize
-                ))
-
-            let frames = Parser.parseFrames replay
-            let expectedFrames = replay
-                    & Type.replayProperties
-                    & Type.unpackDictionary
-                    & Map.lookup ("NumFrames" & Text.pack & Type.PCString)
-                    & Maybe.fromMaybe (Type.IntProperty (Type.Word64LE 4) (Type.Word32LE 0))
-            let actualFrames = frames
-                    & length
-                    & fromIntegral
-                    & Type.Word32LE
-                    & Type.IntProperty (Type.Word64LE 4)
-                    & DeepSeq.deepseq frames
-            Monad.when (expectedFrames /= actualFrames) (error
-                ( "expected "
-                ++ show expectedFrames
-                ++ " frames but found "
-                ++ show actualFrames
-                ++ " frames"
-                ))
-
-            putStr "{\"meta\":"
-            replay & Aeson.encode & BSL8.putStrLn
-            putStr ",\"frames\":"
-            frames & Aeson.encode & BSL8.putStrLn
-            putStrLn "}"
+main :: Prelude.IO ()
+main = do
+    [file] <- Environment.getArgs
+    fullReplay <- FullReplay.unsafeParseReplayFile file
+    DeepSeq.deepseq fullReplay (Prelude.pure ())
+    ByteString.putStrLn (Aeson.encode fullReplay)
diff --git a/library/Octane/Parser.hs b/library/Octane/Parser.hs
--- a/library/Octane/Parser.hs
+++ b/library/Octane/Parser.hs
@@ -36,29 +36,29 @@
         & (\ property -> case property of
             Just (Type.IntProperty _ x) -> x & Type.unpackWord32LE & fromIntegral
             _ -> 0)
-    get = replay & extractContext & getFrames numFrames & Bits.runBitGet
+    get = replay & extractContext & getFrames 0 numFrames & Bits.runBitGet
     stream = replay & Type.replayStream & Type.unpackStream & BSL.fromStrict
     (_context, frames) = Binary.runGet get stream
     in frames
 
-getFrames :: Int -> Context -> Bits.BitGet (Context, [Frame])
-getFrames numFrames context = do
-    if numFrames < 1
+getFrames :: Int -> Int -> Context -> Bits.BitGet (Context, [Frame])
+getFrames number numFrames context = do
+    if number >= numFrames
     then return (context, [])
     else do
         isEmpty <- Bits.isEmpty
         if isEmpty
         then return (context, [])
         else do
-            maybeFrame <- getMaybeFrame context
+            maybeFrame <- getMaybeFrame context number
             case maybeFrame of
                 Nothing -> return (context, [])
                 Just (newContext, frame) -> do
-                    (newerContext, frames) <- getFrames (numFrames - 1) newContext
+                    (newerContext, frames) <- getFrames (number + 1) numFrames newContext
                     return (newerContext, (frame : frames))
 
-getMaybeFrame :: Context -> Bits.BitGet (Maybe (Context, Frame))
-getMaybeFrame context = do
+getMaybeFrame :: Context -> Int -> Bits.BitGet (Maybe (Context, Frame))
+getMaybeFrame context number = do
     time <- getFloat32
     delta <- getFloat32
     if time == 0 && delta == 0
@@ -66,15 +66,17 @@
     else if time < 0.001 || delta < 0.001
     then error ("parsing previous frame probably failed. time: " ++ show time ++ ", delta: " ++ show delta)
     else do
-        (newContext, frame) <- getFrame context time delta
+        (newContext, frame) <- getFrame context number time delta
         return (Just (newContext, frame))
 
-getFrame :: Context -> Time -> Delta -> Bits.BitGet (Context, Frame)
-getFrame context time delta = do
+getFrame :: Context -> Int -> Time -> Delta -> Bits.BitGet (Context, Frame)
+getFrame context number time delta = do
     (newContext, replications) <- getReplications context
     let frame =
             Frame
-            { frameTime = time
+            { frameNumber = number
+            , frameIsKeyFrame = context & contextKeyFrames & Set.member number
+            , frameTime = time
             , frameDelta = delta
             , frameReplications = replications
             }
@@ -606,7 +608,9 @@
 -- | A frame in the net stream. Each frame has the time since the beginning of
 -- the match, the time since the last frame, and a list of replications.
 data Frame = Frame
-    { frameTime :: !Float
+    { frameNumber :: !Int
+    , frameIsKeyFrame :: !Bool
+    , frameTime :: !Float
     , frameDelta :: !Float
     , frameReplications :: ![Replication]
     } deriving (Eq,Generics.Generic,Show)
@@ -696,6 +700,7 @@
     , contextClassPropertyMap :: !ClassPropertyMap
     , contextThings :: !(IntMap.IntMap Thing)
     , contextClassMap :: !ClassMap
+    , contextKeyFrames :: !(Set.Set Int)
     } deriving (Eq, Generics.Generic, Show)
 
 instance DeepSeq.NFData Context
@@ -707,6 +712,13 @@
     , contextClassPropertyMap = CPM.getClassPropertyMap replay
     , contextThings = IntMap.empty
     , contextClassMap = CPM.getActorMap replay
+    , contextKeyFrames = replay
+        & Type.replayKeyFrames
+        & Type.unpackList
+        & map Type.keyFrameFrame
+        & map Type.unpackWord32LE
+        & map fromIntegral
+        & Set.fromList
     }
 
 byteStringToFloat :: BS.ByteString -> Float
diff --git a/octane.cabal b/octane.cabal
--- a/octane.cabal
+++ b/octane.cabal
@@ -3,7 +3,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           octane
-version:        0.7.0
+version:        0.8.0
 synopsis:       Parse Rocket League replays.
 description:    Octane parses Rocket League replays.
 category:       Game
@@ -45,6 +45,7 @@
       Octane
       Octane.Analyzer
       Octane.Data
+      Octane.FullReplay
       Octane.Json
       Octane.Main
       Octane.Parser
diff --git a/package.yaml b/package.yaml
--- a/package.yaml
+++ b/package.yaml
@@ -69,4 +69,4 @@
     - -with-rtsopts=-N
     main: TestSuite.hs
     source-dirs: test-suite
-version: '0.7.0'
+version: '0.8.0'
