Project Status | Finished |
Project Type | Professional / group project |
Project Duration | ~2 years |
Software Used | CryEngine |
Languages Used | C++, Lua, CryEngine Flow Graph |
Primary Role(s) | Lead Programmer |
Rainy Days and Wreckage are two full conversion modifications based on the videogame Crysis I worked on with a permanent team of 4. Spanning over 3 years of active work these 2 projects and an individual level release eventually formed a trilogy of strong plot-driven releases all set in the same storyline. As the Lead Gameplay Programmer I oversaw all phases from pre-production to quality assurance.
Wreckage especially was critically acclaimed, got several magazine covers and up to this day has been downloaded more than 400 000 times. Crytek, impressed with the product's fidelity, gave us permission to do a stand-alone release.
Designed as the finale of the trilogy, Wreckage went through the longest development cycle given it being the most ambitious title we've made so far. After our prior release Rainy Days received praise and a great reception, we wanted to put the bar even higher. Longer playtime and more gameplay variety was the main goal. To this end I created new systems and modified code that firmly moved us away from the base CryEngine 2.
Pulling this kind of production off with a small team while having a polished final product free of bugs and issues proved to be one of the most challenging things I have worked on to date. Despite the looming deathlines, last minute fixes and community pressure, we delivered an end product that I'm immensely proud of to this day.
Level 1 Gameplay Demo
While Wreckage has three levels the very first one went through the most iterations and thus was my main focus point for a lot of the production. The main level scripting was achieved using Lua and with the help of the visual Flow Graph system akin to the Blueprints found in Unreal Engine.
A big emphasis was placed on variety. Over the course of the roughly 60 minutes long level there is a on rails helicopter section, area defence, capturing a village, surviving an ambush, traversing a cave system, infiltrating a facility and finally an explosive finale where a command centre has to defended while multiple waves of enemies close in from all sides.
This level alone took almost a year to fully assemble and sports more than 13 minutes of fully German voiced dialog (or more than 2000 spoken words) which got subtitled in 3 languages including Spanish. All dialog was entirely written by us and recorded by professional voice actors.
To load in a new level the base CryEngine 2 always displays a separate fullscreen loading screen. While this works for the clear cut Wreckage has between Level 1 and 2, Level 2 and 3 are supposed to feel like a single long level so this interruption was bothersome. Therefore I modified the engine code to have a smoother transition:
I decided to capture the last framebuffer and statically display it while overlaying a subtle Flash animation while it loads. Furthermore the player state and position data is saved and applied to the next map allowing you to spawn in the exact same location where the loading trigger was hit. Clever level design does the rest to create a believable illusion.
This code illustrates how the last framebuffer is captured and statically rendered every frame alongside the Flash animation:
void CFlashMenuObject::OnLoadingStart(ILevelInfo *pLevel) { if(gEnv->pSystem->IsEditor() || gEnv->pSystem->IsDedicated()) return; m_bInLoading = true; string levelName = pLevel->GetName(); IGameTokenSystem *pGameTokenSystem = gEnv->pGame->GetIGameFramework()->GetIGameTokenSystem(); string previousLevel; pGameTokenSystem->GetTokenValueAs("Game.General.Previous_Level", previousLevel); CryLogAlways("Wreckage: Previous Level: %s", previousLevel); if (previousLevel == "Wreckage_Part2" && levelName == "Wreckage_Part3") { CryLog("Wreckage: Start part 3 with smooth transition"); //Capture framebuffer gEnv->pRenderer->ScreenShot("Mods/Wreckage/Game/Levels/Wreckage_Part3/wreckage_03_loading_new.jpg", gEnv->pRenderer->GetWidth()); LoadTransitionScreen(pLevel); CryLog("Wreckage: Loading part 3 initiated"); } } ///////////////////////////////////////////////////////////////////////////// void CFlashMenuObject::LoadTransitionScreen(ILevelInfo *pLevel) { if(pLevel) { m_iMaxProgress = pLevel->GetDefaultGameType()->cgfCount; } else { m_iMaxProgress = 100; } if(!m_apFlashMenuScreens[MENUSCREEN_FRONTENDLOADING_TRANSITION]->IsLoaded()) { CryLog("Wreckage: Load transition flash file"); m_apFlashMenuScreens[MENUSCREEN_FRONTENDLOADING_TRANSITION]->Load("Libs/UI/Menus_Loading_Wreckage_Part3.gfx"); m_apFlashMenuScreens[MENUSCREEN_FRONTENDLOADING_TRANSITION]->GetFlashPlayer()->SetFSCommandHandler(>span class="br0">); m_apFlashMenuScreens[MENUSCREEN_FRONTENDLOADING_TRANSITION]->Invoke("SetProgress", 0.0f); UpdateMenuColor(); } m_pCurrentFlashMenuScreen = m_apFlashMenuScreens[MENUSCREEN_FRONTENDLOADING_TRANSITION]; //Prepare static loading texture IRenderer* pRenderer = gEnv->pRenderer; assert( pRenderer != NULL ); if (pRenderer) { IUIDraw *m_pUiDraw = gEnv->pGame->GetIGameFramework()->GetIUIDraw(); assert( m_pUiDraw != NULL ); if (m_pUiDraw) { char fileName[255]; sprintf(fileName,"Game/Levels/Wreckage_Part3/wreckage_03_loading_new.jpg"); m_LoadingTexture = m_pUiDraw->CreateTexture(fileName); pRenderer->SetState( GS_BLSRC_SRCALPHA | GS_BLDST_ONEMINUSSRCALPHA | GS_NODEPTHTEST ); m_bIsQuickLevelSwitchLoading = true; } } m_bUpdate = true; m_nBlackGraceFrames = 0; //Remove gameplay HUD if(g_pGame) g_pGame->DestroyHUD(); } ///////////////////////////////////////////////////////////////////////////// void CFlashMenuObject::OnPostUpdate(float fDeltaTime) //Called every frame just before the framebuffer is displayed { //Wreckage MOD: Draw loading texture if needed if (m_bIsQuickLevelSwitchLoading && m_LoadingTexture != 0) { IRenderer* pRenderer = gEnv->pRenderer; assert( pRenderer != NULL ); if (pRenderer) { pRenderer->Draw2dImage(0.0f,-0.5f, 800.0f,600.0f, m_LoadingTexture, 0.0f,1.0f,1.0f,0.0f, 0.0f); if (m_bInLoading == false && m_nBlackGraceFrames == 0) { m_bIsQuickLevelSwitchLoading = false; //Loading has finished, stop rendering texture after this frame } } } }
In Wreckage we introduced a second playable character for the first time. Captain Lambert joins the team as an ordinary soldier who has to rely on taking cover and running away more often than the original NanoSuit equipped protagonist Nomad who was the only playable character in Rainy Days.
While Nomad is still the avatar for the first level, afterwards the point of view switches to Lambert for Level 2 and 3. The engine was only coded to allow one character model for every level, so I added a new system command to switch between the two heroes:
The command can be invoked from various places and affects the models, difficulty level, HUD, reaction dialogs and movement feel and balance.
When the loading of a map finishes the appropriate player for that level is set automatically:
void CFlashMenuObject::OnLoadingComplete(ILevel *pLevel) { if(gEnv->pSystem->IsEditor() || gEnv->pSystem->IsDedicated()) return; //Wreckage MOD: Load the correct player model and HUD based of level string levelName = pLevel->GetName(); if (levelName == "Wreckage") //Level 1: Nomad gEnv->pConsole->ExecuteString("wreckage_suit_activate"); else if (levelname == "Wreckage_Part2" || levelname == "Wreckage_Part3") //Level 2/3: Lambert gEnv->pConsole->ExecuteString("wreckage_suit_deactivate"); }
The function to switch from Nomad to Lambert. Can be invoked after a level finishes loading, by a custom Flow Graph node or by command line which was used during development in the editor:
//Wreckage MOD: Switch from Nomad-Lambert void CGame::CmdDeactivateSuit(IConsoleCmdArgs* pArgs) { CryLogAlways("Wreckage Log: NanoSuit disabled!"); ICVar* suit = gEnv->pConsole->GetCVar("wreckage_isSuitActivated"); //this variable allows various other systems to check the current player model if(suit) suit->ForceSet("0"); //Change player arms and model to Lambert IScriptSystem* m_pScriptSystem = g_pGame->GetIGameFramework()->GetISystem()->GetIScriptSystem(); CActor* pActor = static_cast<CActor*> (g_pGame->GetIGameFramework()->GetClientActor()); if (pActor) { SmartScriptTable m_script = pActor->GetEntity()->GetScriptTable(); if (m_pScriptSystem) { m_pScriptSystem->BeginCall(m_script, "SetModel"); m_pScriptSystem->PushFuncParam(m_script); m_pScriptSystem->PushFuncParam("objects/characters/human/us/nanosuit/nanosuit_us_multiplayer.cdf"); m_pScriptSystem->PushFuncParam("objects/weapons/arms_lambert/arms_nanosuit_us.chr"); m_pScriptSystem->PushFuncParam("objects/characters/human/asian/nk_soldier/nk_soldier_frozen_scatter.cgf"); m_pScriptSystem->PushFuncParam("Characters/Lambert_fp3p.cdf"); m_pScriptSystem->EndCall(); pActor->Physicalize(); } //Adjust STANCE_STAND speed values to give Lambert higher default walking/running speed CPlayer* pPlayer = static_cast<CPlayer*> (pActor); if (pPlayer) { const SStanceInfo *sInfo = pPlayer->GetStanceInfo(STANCE_STAND); SStanceInfo newStandInfo; //Copy old values since they remain the same newStandInfo.heightCollider = sInfo->heightCollider; newStandInfo.heightPivot = sInfo->heightPivot; newStandInfo.leanLeftViewOffset = sInfo->leanLeftViewOffset; newStandInfo.leanLeftWeaponOffset = sInfo->leanLeftWeaponOffset; newStandInfo.leanRightViewOffset = sInfo->leanRightViewOffset; newStandInfo.leanRightWeaponOffset = sInfo->leanRightWeaponOffset; newStandInfo.modelOffset = sInfo->modelOffset; strcpy(newStandInfo.name, sInfo->name); newStandInfo.size = sInfo->size; newStandInfo.useCapsule = sInfo->useCapsule; newStandInfo.viewOffset = sInfo->viewOffset; newStandInfo.weaponOffset = sInfo->weaponOffset; //Set new speed values newStandInfo.normalSpeed = 4; newStandInfo.maxSpeed = 9; pPlayer->SetupStance(STANCE_STAND, &newStandInfo); } } //Set suit mode to default for safety since it's no longer in use CPlayer *pPlayer = static_cast<CPlayer *>(g_pGame->GetIGameFramework()->GetClientActor()); if(pPlayer) { CNanoSuit *m_pNanoSuit = pPlayer->GetNanoSuit(); m_pNanoSuit->SetMode(NANOMODE_DEFENSE, true); } //This is only relevant when triggering a change mid level from the command line. Refresh the currently used player item so the new model is used if (gEnv->pSystem->IsEditor()) { //Select player fists as default weapon if (pActor && pPlayer) pPlayer->SelectFists(true); } //Set custom difficulty level for Lambert g_pGame->GetMenu()->SetDifficulty(0); //Change the HUD g_pGame->GetHUD()->SetLambertHUDEnabled(true); }
This function is used to re-select the player arms in case the player character is switched mid level so the new arm model gets used. Only relevant during development:
void CPlayer::SelectFists(bool active) { if(active) { COffHand *pOffHand = static_cast<COffHand*>(GetItemByClass(CItem::sOffHandClass)); //Drop object or NPC if player has grabbed something if (pOffHand) { if(pOffHand->IsHoldingEntity()) { //Force drop if(pOffHand->GetOffHandState() == eOHS_MELEE) { pOffHand->GetScheduler()->Reset(); pOffHand->SetOffHandState(eOHS_HOLDING_OBJECT); } pOffHand->OnAction(GetEntityId(),"use",eAAM_OnPress,0); pOffHand->OnAction(GetEntityId(),"use",eAAM_OnRelease,0); } else if(pOffHand->GetOffHandState() == eOHS_HOLDING_GRENADE) { pOffHand->FinishAction(eOHA_RESET); } } //Select fists as default weapon CFists *pFists = static_cast<CFists*>(GetItemByClass(CItem::sFistsClass)); if(!pFists) return; CItem *currentItem = static_cast<CItem*>(GetCurrentItem()); if(!currentItem) { //Player has no item selected...just select fists pFists->EnableAnimations(false); SelectItem(pFists->GetEntityId(), false); pFists->EnableAnimations(true); } else if(currentItem->GetEntityId() == pFists->GetEntityId()) { //Fists already selected...refresh them pFists->Select(false); SelectItem(pFists->GetEntityId(),false); GetInventory()->SetLastItem(pFists->GetEntityId()); pFists->Select(true); } else { //Deselect currently used item and select fists after currentItem->Select(false); pFists->EnableAnimations(false); SelectItem(pFists->GetEntityId(), false); pFists->EnableAnimations(true); } } }
At last the function that handles switching the HUD:
void CHUD::SetLambertHUDEnabled(bool bEnabled) { if (m_animPlayerStats.GetVisible()) { if (bEnabled) { //Custom Lambert HUD m_animPlayerStats.Load("Libs/UI/HUD_Lambert.gfx", eFD_Right, eFAF_Visible | eFAF_ThisHandler); } else { //Default Nomad HUD m_animPlayerStats.Load("Libs/UI/HUD_AmmoHealthEnergySuit.gfx", eFD_Right, eFAF_Visible | eFAF_ThisHandler); EnergyChanged(m_pNanoSuit->GetSuitEnergy()); } } //Update Health CActor *pActor = static_cast<CActor *>(gEnv->pGame->GetIGameFramework()->GetClientActor()); if (pActor) { float fHealth = (pActor->GetHealth() / (float)pActor->GetMaxHealth()) * 100.0f + 1.0f; m_animPlayerStats.Invoke("setHealth", (int)fHealth); } //Update FireMode m_animPlayerStats.Invoke("setFireMode", m_curFireMode); //Update Ammo SFlashVarValue args[7] = { 0, m_playerAmmo, m_playerClipSize, m_playerRestAmmo, m_iGrenadeAmmo, m_sGrenadeType, true }; m_animPlayerStats.Invoke("setAmmo", args, 7); }
To allow easy re-playability and to have a set point to go back to in case of a game crash or critical bug occurring I added a completely new chapter system into the Engine. Flow Graph nodes unlock them based on story progress inside the level, which makes them appear in this newly added UI sub menu:
If the player decides to load a chapter the corresponding level is loaded in fresh. Then the chapter name is sent to the level Flow Graph to spawn the player in the appropriate place and it sets all relevant progression flags instead of starting from the beginning of the level.
To speed up development and allow new gameplay features while scripting the levels I wrote several custom C++ Flow Graph Nodes that either expose useful engine functions or add entirely new functionality:
The following code file illustrates two of the new Flow Graph Nodes that I added into the CryEngine 2. The first can be used to force the player into a specific stance while the second one allows to set corresponding speed values:
///////////////////////////////////////////////////////////////////////////// // FlowForcePlayerStance.cpp // // Purpose: Flow node for forcing player into a stance // ///////////////////////////////////////////////////////////////////////////// #include "StdAfx.h" #include "Nodes/G2FlowBaseNode.h" #include "Player.h" #include "PlayerInput.h" ///////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////// class CFlowNode_ForcePlayerStance : public CFlowBaseNode { private: SActivationInfo m_actInfo; public: enum EInputPorts { EIP_Trigger = 0, EIP_Stance, }; enum EOutputPorts { EOP_Done = 0, }; //////////////////////////////////////////////////// virtual ~CFlowNode_ForcePlayerStance(void) { } //////////////////////////////////////////////////// virtual void Serialize(SActivationInfo *pActInfo, TSerialize ser) { if (ser.IsReading()) m_actInfo = *pActInfo; } //////////////////////////////////////////////////// virtual void GetConfiguration(SFlowNodeConfig& config) { static const SInputPortConfig inputs[] = { InputPortConfig_Void ("Trigger", _HELP("Trigger")), InputPortConfig<int>( "Stance", -1, _HELP("Try to set Stance on Player"), 0, _UICONFIG("enum_int:Stand=0,Crouch=1,Prone=2")), {0} }; static const SOutputPortConfig outputs[] = { OutputPortConfig_Void ("Done", _HELP("Set Stance Done")), {0} }; config.pInputPorts = inputs; config.pOutputPorts = outputs; config.sDescription = _HELP("Tries to force the player into a stance"); config.SetCategory(EFLN_APPROVED); } //////////////////////////////////////////////////// virtual void ProcessEvent(EFlowEvent event, SActivationInfo *pActInfo) { switch (event) { case eFE_Initialize: { m_actInfo = *pActInfo; } break; case eFE_Activate: { if (IsPortActive(pActInfo, EIP_Trigger)) { CActor *pPlayerActor = static_cast<CActor *>(gEnv->pGame->GetIGameFramework()->GetClientActor()); if (pPlayerActor) { CPlayer* pPlayer = static_cast<CPlayer*> (pPlayerActor); if (pPlayer) { int desiredStance = GetPortInt(pActInfo, EIP_Stance); IPlayerInput* pPlayerInput = pPlayer->GetPlayerInput(); if(pPlayerInput) pPlayerInput->Reset(); pPlayer->SetStance((EStance) desiredStance); IPhysicalEntity *pPhysEnt = pPlayer->GetEntity()->GetPhysics(); if (pPhysEnt) { const SStanceInfo *sInfo = pPlayer->GetStanceInfo((EStance) desiredStance); pe_player_dimensions playerDim; playerDim.heightEye = 0.0f; playerDim.heightCollider = sInfo->heightCollider; playerDim.sizeCollider = sInfo->size; playerDim.heightPivot = sInfo->heightPivot; playerDim.maxUnproj = max(0.0f,sInfo->heightPivot); playerDim.bUseCapsule = sInfo->useCapsule; pPhysEnt->SetParams(&playerDim); } pPlayer->StanceChanged((EStance) desiredStance); //Request new animation stance if (pPlayer->GetAnimatedCharacter() != NULL) pPlayer->GetAnimatedCharacter()->RequestStance(desiredStance, pPlayer->GetStanceInfo((EStance) desiredStance)->name); //Awake entity just in case if (pPhysEnt) { pe_action_awake aa; aa.bAwake = 1; pPhysEnt->Action(&aa); } if(pPlayerInput && desiredStance == STANCE_PRONE) pPlayerInput->OnAction("prone",eAAM_OnPress,1.0f); else if(pPlayerInput && desiredStance == STANCE_CROUCH) pPlayerInput->OnAction("crouch",eAAM_OnPress,1.0f); ActivateOutput(pActInfo, EOP_Done, true); } } ActivateOutput(pActInfo, EOP_Done, false); } } break; } } //////////////////////////////////////////////////// virtual void GetMemoryStatistics(ICrySizer *s) { s->Add(*this); } //////////////////////////////////////////////////// virtual IFlowNodePtr Clone(SActivationInfo *pActInfo) { return new CFlowNode_ForcePlayerStance(); } }; ///////////////////////////////////////////////////////////////////////////// // Purpose: Flow node for adjusting the speed values of a particular stance ///////////////////////////////////////////////////////////////////////////// class CFlowNode_ChangeStanceSpeed : public CFlowBaseNode { private: SActivationInfo m_actInfo; public: enum EInputPorts { EIP_Trigger = 0, EIP_Stance, EIP_NormalSpeed, EIP_MaxSpeed, }; enum EOutputPorts { EOP_Done = 0, }; //////////////////////////////////////////////////// virtual ~CFlowNode_ChangeStanceSpeed(void) { } //////////////////////////////////////////////////// virtual void Serialize(SActivationInfo *pActInfo, TSerialize ser) { if (ser.IsReading()) m_actInfo = *pActInfo; } //////////////////////////////////////////////////// virtual void GetConfiguration(SFlowNodeConfig& config) { static const SInputPortConfig inputs[] = { InputPortConfig_Void ("Trigger", _HELP("Trigger")), InputPortConfig<int>( "Stance", -1, _HELP("Player stance"), 0, _UICONFIG("enum_int:Stand=0,Crouch=1,Prone=2")), InputPortConfig<float>( "NormalSpeed", 0.0f, _HELP("Normal speed for the stance (walk)")), InputPortConfig<float>( "MaxSpeed", 0.0f, _HELP("Max speed for the stance (sprint)")), {0} }; static const SOutputPortConfig outputs[] = { OutputPortConfig_Void ("Done", _HELP("Change stance speed Done")), {0} }; config.pInputPorts = inputs; config.pOutputPorts = outputs; config.sDescription = _HELP("Tries to change the speed of a stance for the player"); config.SetCategory(EFLN_APPROVED); } //////////////////////////////////////////////////// virtual void ProcessEvent(EFlowEvent event, SActivationInfo *pActInfo) { switch (event) { case eFE_Initialize: { m_actInfo = *pActInfo; } break; case eFE_Activate: { if (IsPortActive(pActInfo, EIP_Trigger)) { CActor *pPlayerActor = static_cast<CActor *>(gEnv->pGame->GetIGameFramework()->GetClientActor()); if (pPlayerActor) { CPlayer* pPlayer = static_cast<CPlayer*> (pPlayerActor); if (pPlayer) { int targetStance = GetPortInt(pActInfo, EIP_Stance); float targetNormalSpeed = GetPortFloat(pActInfo, EIP_NormalSpeed); float targetMaxSpeed = GetPortFloat(pActInfo, EIP_MaxSpeed); const SStanceInfo *sInfo = pPlayer->GetStanceInfo((EStance)targetStance); SStanceInfo newStanceInfo; //Copy old values that remain the same newStanceInfo.heightCollider = sInfo->heightCollider; newStanceInfo.heightPivot = sInfo->heightPivot; newStanceInfo.leanLeftViewOffset = sInfo->leanLeftViewOffset; newStanceInfo.leanLeftWeaponOffset = sInfo->leanLeftWeaponOffset; newStanceInfo.leanRightViewOffset = sInfo->leanRightViewOffset; newStanceInfo.leanRightWeaponOffset = sInfo->leanRightWeaponOffset; newStanceInfo.modelOffset = sInfo->modelOffset; strcpy(newStanceInfo.name, sInfo->name); newStanceInfo.size = sInfo->size; newStanceInfo.useCapsule = sInfo->useCapsule; newStanceInfo.viewOffset = sInfo->viewOffset; newStanceInfo.weaponOffset = sInfo->weaponOffset; //Set new speed values newStanceInfo.normalSpeed = targetNormalSpeed; newStanceInfo.maxSpeed = targetMaxSpeed; pPlayer->SetupStance((EStance)targetStance, &newStanceInfo); ActivateOutput(pActInfo, EOP_Done, true); break; } } ActivateOutput(pActInfo, EOP_Done, false); } } break; } } //////////////////////////////////////////////////// virtual void GetMemoryStatistics(ICrySizer *s) { s->Add(*this); } //////////////////////////////////////////////////// virtual IFlowNodePtr Clone(SActivationInfo *pActInfo) { return new CFlowNode_ChangeStanceSpeed(); } }; //////////////////////////////////////////////////// //////////////////////////////////////////////////// REGISTER_FLOW_NODE("Game:ForcePlayerStance", CFlowNode_ForcePlayerStance); REGISTER_FLOW_NODE("Game:ChangePlayerStanceSpeed", CFlowNode_ChangeStanceSpeed);
With over 7000 spoken words total, subtitling Wreckage was no easy task. CryEngine already includes a robust system to create subtitles and add additional effects (such as static) to the audio in real time without having to alter the dialog files themselves. But the format wasn't well documented nor very user friendly to actually work with, so I created a very simple editor which the Lead Story Writer was able to use:
Effects and properties could be selected from dropdowns and sliders e.g. how much to fade all other audio while this dialog is playing with tooltip support explaining them.
Since the engine didn't handle line breaks automatically they had to be manually added into the subtitle as well as determining when to show an entire new chunk. To facilitate rapid subtitle creation I imported the actual flash file which was used to render the subtitles in the game into this tool so you were able to quickly preview how your subtitles and line breaks were going to appear in the real game later:
Entire batches of dialog could be handled within minutes while producing pleasing looking results that were matching the audio speed.
Production on Wreckage started very early on after Rainy Days was released and took us on a long journey. In this section you'll find a few snippets of how the development progressed.
Cherokee was the original vision for Wreckage but due technical issues, such as the terrain size being way too big for the CryEngine 2 to handle properly, we ultimately shelved it and it transitioned into the present day Wreckage.
The following video showcases some of the content that got cut during development as well as the transition from key scenes from the earliest stages to the final game:
Our hard work on Wreckage and listening to the feedback we received from Rainy Days paid off in near universal acclaim. We got inquiries from several PC print magazines such as the German GameStar and PC Action and the Australian PC PowerPlay to include our Mod on their DVD and dedicate a section of an issue to Wreckage.
PC Action also reviewed the Mod and gave it an 'Excellent':
The reception from players was similarly enthusiastic which expressed itself in high download counts and positive reviews on Mod DB with an average rating of 9/10:
Here is what some of Wreckage's players had to say:
The creator of the Crysis franchise, Crytek, noticed our Mod as well and were so impressed they gave us permission in coordination with EA to do a stand alone release of Wreckage without Crysis included. This resulted in massive download numbers and even more positive reviews.
As a thank you to Crytek we sent them a letter and Wreckage burned on DVD all packaged up in a mock-up physical case: