L4D2 Level Design/Scavenge Finale

From Valve Developer Community
Jump to: navigation, search

<Left 4 Dead 2> Scavenge finales consist of an arena where infected attempt to stop survivors from filling up an escape vehicle with gas cans scattered throughout the map. Versus mode scoring depends on the number of gas cans filled during a round. Compared to a standard finale, there are some additional entities and a reliance on vscripts in order for scavenge finale to work.


c1m4_atrium decompiled will be used as a reference. Another map is c6m3_port, which also features L4D1 survivors bots. This is a rough list of components involved with scavenge finale:

Note.png Note: c1m4_atrium_finale.nut is loaded automatically on finale start. It follows naming convention scripts/vscripts/<map name>_finale.nut


In general, the space needs to provide obstacles between the survivors and the gas cans. The obstacle is typically distance/terrain between the nozzle (point_prop_use_target) and the gas can.


Consider that the survivors need starting weapons for when they reach the finale. They still need to be able to stock up for the challenge ahead of them. There should also be weapons and items on the way so that survivors can hold out for a bit to heal or resupply. There will be many different ways someone playing your map will go, for example someone that likes to take their time compared to someone that sparatically runs through your map to try and quickly grab the cans to escape.


These are merely suggestions:

  • Make branching paths so that the survivors can duck in and out of buildings for supplies or even gascans for the finale.
  • Keep in mind players dont constantly want to have to go down narrow hallways make open areas for large combat scenarios where the survivors can go down a street for example but be attacked by infected spawning in nearby building or special infected attacking them from rooftops.
  • The distance between the gas can and goal is a balance of advantages between two teams.

Scavenge finale entities

  • Firstly you will need a trigger_finale so the survivors can start the finale. In that entity you will find various settings. One of these is the finale type. It's default is set to standard. You will want to change this to scavenge. For now leave the use delay the same, but change the first use delay to five seconds. This lets the survivors know what their doing by making a sound play. (This is not necessary but it does help)
  • Second you want to place a game_scavenge_progress_display. Inside it, you can change the max value to whatever you want. For the purpose of this tutorial we'll make it 8. Then change its name to something unique. For ours it will be scav_progshower. Next you will need a math_counter. Change its name property to scav_counter, then leave its initial value to 0. Make the max value the same number you set in the game_scavenge_progress_display. For this tutorial, we have it set to 8. Leave it's min value at 0.
  • Third you will need to make the location where you will fill up. This can be anything you choose. For this tutorial, we will be using the nozzle prop radio_generator_fillup.mdl. You will need to place a prop_dynamic, then in its model field look up radio_generator_fillup. Place this within reachable distance from the player (player height is about 64 units so a good height will be 45 to 50 units). Set its glow color to any color of your choice.
  • Fourth you will need to place a point_prop_use_target on the nozzle.

Hammer scavtut usetarget.jpg

Go to your nozzle and open its properties and set its name field to scav_nozzle. Then go back to your point_prop_use_target and open up its output menu. Add a new output with the following settings :

  • My output named: OnUseFinished
  • Targets entities named: Scav_counter
  • Via this input: Add
  • With a parameter override of: +1

This means when a player finishes filling up the nozzle with a gas can, point_prop_use_target will add a point to scav_counter.

Hammer scavtut output.jpeg

Gas cans

In the entities tab look up weapon_scavenge_item_spawn. Place it down and go into its properties. Change its name to scav_gascans. Then go back to your finale trigger, go into its properties, go to outputs tab and click add

Input the following:

  • My output named: UseStart
  • Targets entities named: scav_gascans
  • Via this input: TurnGlowsOn

(The above means when the finale has started, all entities named scav_gascans will glow.)

Add another output with the following:

  • My output named: UseStart
  • Targets entities named: scav_nozzle
  • Via this input: StartGlowing

(Similarly to above, this will make the nozzle glow.)

Next go to your entities tab and look up logic_auto. Place it down and go to its outputs tab and click add.

Input the following:

  • My output named: OnMapSpawn
  • Targets entities named: scav_gascans
  • Via this input: TurnGlowsOff

Add another output and input the following:

  • My output named: OnMapSpawn
  • Targets entities named: scav_nozzle
  • Via this input: StopGlowing

(The above two ensure they do not glow until we start the finale.)

Add another output and input the following:

  • My output named: OnMapSpawn
  • Targets entities named: scav_progshower
  • Via this input: TurnOff

(This ensures the progress shower isn't active until we enable it.)

Hammer scavtut autooutput.jpeg

Escape vehicle

  • This is usually a prop_dynamic with animations but it really can be anything.

For the purpose of this tutorial we'll just use the C130 for the prop_dynamic escape vehicle. Go to the texture browser and look up trigger. It will look like this:

Hammer scavtut triggertex.jpeg

Make a brush the size of your escape vehicle. This would be the inside of the C130. Hit enter to create it, then hit ctrl+t to tie to entity. Under the class type put in trigger_multiple and give it the name escape_trigger. Then set the entire team number to survivor and set start disabled to yes. Then close out of its properties.

Go back to your math_counter and to the outputs tab. Add a new output:

  • My output named: OnHitMax
  • Targets entities named: Scav_finale_starter
  • Via this input: FinaleEscapeVehicleReadyForSurvivors
  • Fire Once Only: click the checkbox

  • My output named: OnHitMax
  • Targets entities named: escape_trigger
  • Via this input: Enable
  • Fire Once Only: click the checkbox

  • My output named: OnHitMax
  • Targets entities named: scav_gascans
  • Via this input: TurnGlowsOff
  • Fire Once Only: click the checkbox

  • My output named: OnHitMax
  • Targets entities named: scav_usenozzle
  • Via this input: Kill
  • Fire Once Only: click the checkbox

  • My output named: OnHitMax
  • Targets entities named: scav_nozzle
  • Via this input: StopGlowing
  • Fire Once Only: click the checkbox

Hammer scavtut counteroutputs.jpeg

Then go back to your finale_trigger and change its name field to Scav_finale_starter.

Escape sequence

You will need to make a box outside the reach of your map with 4 info_survivor_position. In the properties, change the first info_survivor_position to 1. Change the second to 2 and so on until you've reached 4. Then change their names to whatever order you put them. (Ex. survivor_pos1 survivor_pos2 etc.)

Warning.png Warning: Sometimes there is a problem with spawning outside the map in this box its advisable to not do this.
  • Go to your trigger_escape (the trigger brush you made in the escape vehicle) and add a new output with the following:

  • My output named: OnEntireTeamStartTouch
  • Targets entities named: Scav_finale_starter
  • Via this input: FinaleEscapeForceSurvivorPositions

  • My output named: OnEntireTeamStartTouch
  • Targets entities named: Scav_finale_starter
  • Via this input: FinaleEscapeFinished

Hammer scavtut escapetrgoutputs.jpeg

Then go back to the texture browser. Look for clip; it will look like this:

Hammer scavtut cliptex.jpeg

Surround the survivor positions outside the reach of your map with this brush and hit enter to create it. Then click crtl+t to tie to entity. Change class type to func_brush. Look for Solid BSP and set it to yes. Then find Solidity and set it to always solid. Lastly, change its name to clip_scavend, hit apply, and close its properties menu.

Go back to your trigger_multiple and in the properties field click add

  • My output named: OnEntireTeamStartTouch
  • Targets entities named: clip_scavend
  • Via this input: Kill

Cover the floor of the escape vehicle with nodraw so you can walk on it and create navigation meshes within it. Then in the entities tab look for point_veiwcontrol_multiplayer. Place this somewhere to view your escape vehicle leaving, then open up its properties and name it outro_cam. Then look for an env_fade entity and place 2 of them. Name the first one fade1 and set its hold time to 1. Then set its fade out/in to .15.

(What this does is instead of seeing the player and then quickly switching to the camera, it allows for a fade to happen. The survivors will teleport to their positions and then the view will come back to the camera.)

In the outputs tab of the first fade click add

  • My output named: OnFade
  • Target entities named: fade2
  • Via this input: Fade
  • After a delay of: 6

Then go back to the trigger_escape that is inside the escape vehicle and go to its outputs click add

  • My output named: OnEntireTeamStartTouch
  • Target entities named: outro_cam
  • Via this input: Enable

Go to the fade2 entity and in its outputs tab click add

  • My output named: OnFade
  • Target entities named: outro_stats
  • Via this input: RollCredits

Then in the entities tab look up env_outro_credits. In its properties set its name field as outro_stats.

Nav mesh

Run your map, once you are in the level hit the tilde key {~} to open the developer console then type Hammer scavtut devconsole.jpeg

  • sv_cheats 1- This allows cheats to be enabled in game
  • noclip- Allows free movement around the map, you will not be blocked by anything
  • nav_edit 1- Enables nav mesh editting
  • z_debug 1- Allows you to see navigation attributes and all zombies on the map
  • director_stop- Stops the director from spawning zombies
  • nb_delete_all- Deletes all npc's on the map

Then go to you finale area and open the developer console and type in

  • nav_mark_walkable- Marks a walkable point in the map marked by a purple pyramid that can be generated into nav meshes
  • nav_generate_incremental- Generates the nav meshes to a certain distance from the nav_mark_walkable

Then make sure you have the areas selected that you want around the finale trigger, as well as distanced areas for the zombies to spawn from. Then type in the following in the developer console:

  • mark Finale- This an attribute for nav meshes that is used in the final level of a campaign and is used in conjunction with a trigger_finale.
Note.png Note: To make the navigation work there must be a navigation mesh marked with the checkpoint attribute.

Hammer scavtut navrescue.jpeg

Then go to your player start area and mark those areas with checkpoint and player start. Finally you need to go to your escape vehicle and mark those nav areas inside with rescue vehicle. Hammer scavtut navfinale.jpeg After that is done, type the following in the developer console:

  • nav_analyze- (This analyzes all the nav meshes and writes a file named "yourmapname".nav in the maps folder of your left4dead2 folder.)

This is all you need to make a scavenge finale, I hope this helped


There are three vscripts are involved in this scavenge finale. Note that some code is redundant or commented out, possibly marks of the development process. Included are additional comments:


This script needs to be loaded on MapSpawn manually as a Director script, using the BeginScript input of the info_director entity. It sets the number of gas cans, the default CommonLimit, unblocks the rescue vehicle nav area, and (for some reason) declares the function GasCanPoured().

  • The number of gas cans will decrease if it is a single player game
  • Since human players will be navigating on the rescue vehicle, the rescue vehicle nav area is unblocked.
  • GasCanPoured() will be called either by the director (OnTeamScored) or point_prop_use_target (OnUsedFinished) whenever a gas can is successfully poured.
Msg(" atrium map script "+"\n")

// number of cans needed to escape.

if ( Director.IsSinglePlayerGame() )
	NumCansNeeded <- 8
	NumCansNeeded <- 13

// This script is called on MapSpawn, so the CommonLimit is for play before the finale start.
DirectorOptions <-
CommonLimit = 15


NavMesh.UnblockRescueVehicleNav() // Unblock so humans can be rescued when incapped near nozzle

EntFire( "progress_display", "SetTotalItems", NumCansNeeded ) //Set number of cans with game_scavenge_progress_display

function GasCanPoured(){} // Declaration of function, but was moved to main finale script


This script is loaded as soon as the finale is forced to start (when the elevator opens at the lowest floor of the atrium). It contains most of the finale settings and logic.

  • There are multiple finale stages, either onslaught, panic, or tank. The first stage is an onslaught and uses InitialOnslaughtOptions for DirectorOptions. The will end once gas cans are picked up four times or one gas can is successfully poured, leading to a PANIC stage
  • The rest of the onslaughts run off c1m4_delay.nut for DirectorOptions. This onslaught is time dependent and has a lower tolerance for the number of gas can touches before moving to the next stage.
Msg("----------------------FINALE SCRIPT------------------\n")
// Stage type enumerations
PANIC <- 0
TANK <- 1
DELAY <- 2
// Initialized tables along with stage settings

SharedOptions <-
// Base DirectorOptions
 	A_CustomFinale1 = ONSLAUGHT //Will be stopped with input to director, EndCustomScriptedStage
	A_CustomFinaleValue1 = "" //InitialOnslaughtOptions is slightly different from c1m4_delay

	A_CustomFinale2 = PANIC
	A_CustomFinaleValue2 = 1 //1 PANIC wave

	A_CustomFinale3 = ONSLAUGHT
	A_CustomFinaleValue3 = "c1m4_delay" //This onslaught also depends on timer
	A_CustomFinale4 = PANIC
	A_CustomFinaleValue4 = 1

	A_CustomFinale5 = ONSLAUGHT
	A_CustomFinaleValue5 = "c1m4_delay"

	A_CustomFinale6 = TANK
	A_CustomFinaleValue6 = 1

	A_CustomFinale7 = ONSLAUGHT
	A_CustomFinaleValue7 = "c1m4_delay"
 	A_CustomFinale8 = PANIC
	A_CustomFinaleValue8 = 1

	A_CustomFinale9 = ONSLAUGHT
	A_CustomFinaleValue9 = "c1m4_delay"
 	A_CustomFinale10 = PANIC
	A_CustomFinaleValue10 = 1

	A_CustomFinale11 = ONSLAUGHT
	A_CustomFinaleValue11 = "c1m4_delay"

	A_CustomFinale12 = PANIC
	A_CustomFinaleValue12 = 1
 	A_CustomFinale13 = ONSLAUGHT
	A_CustomFinaleValue13 = "c1m4_delay"
	A_CustomFinale14 = TANK
	A_CustomFinaleValue14 = 1 //1 TANK
 	A_CustomFinale15 = ONSLAUGHT
	A_CustomFinaleValue15 = "c1m4_delay"
	A_CustomFinale16 = PANIC
	A_CustomFinaleValue16 = 1  
 	A_CustomFinale17 = ONSLAUGHT
	A_CustomFinaleValue17 = "c1m4_delay"    
 	A_CustomFinale18 = PANIC
	A_CustomFinaleValue18 = 1  
 	A_CustomFinale19 = ONSLAUGHT
	A_CustomFinaleValue19 = "c1m4_delay"
	A_CustomFinale20 = PANIC
	A_CustomFinaleValue20 = 1   
 	A_CustomFinale21 = ONSLAUGHT
	A_CustomFinaleValue21 = "c1m4_delay"
	A_CustomFinale22 = TANK
	A_CustomFinaleValue22 = 1  
 	A_CustomFinale23 = ONSLAUGHT
	A_CustomFinaleValue23 = "c1m4_delay"    
 	A_CustomFinale24 = PANIC
	A_CustomFinaleValue24 = 1
 	A_CustomFinale25 = ONSLAUGHT
	A_CustomFinaleValue25 = "c1m4_delay"
	A_CustomFinale26 = PANIC
	A_CustomFinaleValue26 = 1   
 	A_CustomFinale27 = ONSLAUGHT
	A_CustomFinaleValue27 = "c1m4_delay"
	A_CustomFinale28 = PANIC
	A_CustomFinaleValue28 = 1  
 	A_CustomFinale29 = ONSLAUGHT
	A_CustomFinaleValue29 = "c1m4_delay"    
 	A_CustomFinale30 = PANIC
	A_CustomFinaleValue30 = 1

 	A_CustomFinale31 = ONSLAUGHT
	A_CustomFinaleValue31 = "c1m4_delay"   
        // End of finale, regardless of gas cans filled
        // More Default DirectorOptions

	PreferredMobDirection = SPAWN_LARGE_VOLUME
	PreferredSpecialDirection = SPAWN_LARGE_VOLUME
//	BoomerLimit = 0
//	SmokerLimit = 2
//	HunterLimit = 1
//	SpitterLimit = 1
//	JockeyLimit = 0
//	ChargerLimit = 1

	ProhibitBosses = true
	ZombieSpawnRange = 3000
	MobRechargeRate = 0.5
	HordeEscapeCommonLimit = 15
	BileMobSize = 15
	MusicDynamicMobSpawnSize = 8
	MusicDynamicMobStopSize = 2
	MusicDynamicMobScanStopSize = 1

InitialOnslaughtOptions <-
// DirectorOptions for first onslaught
    LockTempo = 0
	IntensityRelaxThreshold = 1.1
	RelaxMinInterval = 2
	RelaxMaxInterval = 4
	SustainPeakMinTime = 25
	SustainPeakMaxTime = 30
	MobSpawnMinTime = 4
	MobSpawnMaxTime = 8
	MobMinSize = 2
	MobMaxSize = 6
	CommonLimit = 5
	SpecialRespawnInterval = 100

PanicOptions <-
// DirectorOptions when in a PANIC stage

	MegaMobSize = 0 // randomized in OnBeginCustomFinaleStage
	MegaMobMinSize = 20
	MegaMobMaxSize = 40
	CommonLimit = 15
	SpecialRespawnInterval = 40

TankOptions <-
// DirectorOptions when in a TANK stage
	ShouldAllowMobsWithTank = true
	ShouldAllowSpecialsWithTank = true

	MobSpawnMinTime = 10
	MobSpawnMaxTime = 20
	MobMinSize = 3
	MobMaxSize = 5

	CommonLimit = 7
	SpecialRespawnInterval = 60

DirectorOptions <- clone SharedOptions
// Start with SharedOptions


// number of cans needed to escape. again. (Later moved to c1m4_atrium.nut)
NumCansNeeded <- 13

// fewer cans in single player since bots do not help much
if ( Director.IsSinglePlayerGame() )
	NumCansNeeded <- 8

// duration of delay stage.
DelayMin <- 10
DelayMax <- 20

// Number of touches and/or pours allowed before a delay is aborted.
DelayPourThreshold <- 1
DelayTouchedOrPouredThreshold <- 2

// Once the delay is aborted, amount of time before it progresses to next stage.
AbortDelayMin <- 1
AbortDelayMax <- 3

// Number of touches and pours it takes to transition out of c1m4_finale_wave_1
GimmeThreshold <- 4

// console overrides
if ( Director.IsPlayingOnConsole() )
	DelayMin <- 20
	DelayMax <- 30
	// Number of touches and/or pours allowed before a delay is aborted.
	DelayPourThreshold <- 2
	DelayTouchedOrPouredThreshold <- 4
	TankOptions.ShouldAllowSpecialsWithTank = false
//      INIT

GasCansTouched          <- 0
GasCansPoured           <- 0
DelayTouchedOrPoured    <- 0
DelayPoured             <- 0

EntFire( "timer_delay_end", "LowerRandomBound", DelayMin )
EntFire( "timer_delay_end", "UpperRandomBound", DelayMax )
EntFire( "timer_delay_abort", "LowerRandomBound", AbortDelayMin )
EntFire( "timer_delay_abort", "UpperRandomBound", AbortDelayMax )

// this is occurs too late. moved to c1m4_atrium.nut
//EntFire( "progress_display", "SetTotalItems", NumCansNeeded )

function AbortDelay(){}  	// only defined during a delay, in c1m4_delay.nut
function EndDelay(){}		// only defined during a delay, in c1m4_delay.nut

NavMesh.UnblockRescueVehicleNav() // This is redundant since it was already done once


function GasCanTouched()
// This is called by weapon_scavenge_item_spawn OnItemPickedUp
    Msg(" Touched: " + GasCansTouched + "\n")   
function GasCanPoured()
// In this map, it is called by the director OnTeamScored
    Msg(" Poured: " + GasCansPoured + "\n")   

    if ( GasCansPoured == NumCansNeeded )
        Msg(" needed: " + NumCansNeeded + "\n") 
        EntFire( "relay_car_ready", "trigger" )


function EvalGasCansPouredOrTouched()
// Evaluate the number of times gas cans poured or touched
    TouchedOrPoured <- GasCansPoured + GasCansTouched
    Msg(" Poured or touched: " + TouchedOrPoured + "\n")

    Msg(" DelayTouchedOrPoured: " + DelayTouchedOrPoured + "\n")
    Msg(" DelayPoured: " + DelayPoured + "\n")
    if (( DelayTouchedOrPoured >= DelayTouchedOrPouredThreshold ) || ( DelayPoured >= DelayPourThreshold ))
    // This is for c1m4_delay.nut (c1m4_delay.nut also resets the counter for Poured and TouchOrPoured)
    switch( TouchedOrPoured ) //For stopping the InitialOnslaught (first stage)
        case GimmeThreshold:
            EntFire( "@director", "EndCustomScriptedStage" )

function AddTableToTable( dest, src )
// This function is used to move table keys and values to other tables
	foreach( key, val in src )
		dest[key] <- val

function OnBeginCustomFinaleStage( num, type )
// Special Function every time a finale stage starts.
// Instructions in this function set DirectorOptions for PANIC and TANK
	printl( "Beginning custom finale stage " + num + " of type " + type );
	local waveOptions = null
	if ( num == 1 )
		waveOptions = InitialOnslaughtOptions
	else if ( type == PANIC )
		waveOptions = PanicOptions
		waveOptions.MegaMobSize = PanicOptions.MegaMobMinSize + rand()%( PanicOptions.MegaMobMaxSize - PanicOptions.MegaMobMinSize )
		Msg("*************************" + waveOptions.MegaMobSize + "\n")
	else if ( type == TANK )
		waveOptions = TankOptions


	AddTableToTable( MapScript.DirectorOptions, SharedOptions );

	if ( waveOptions != null )
		AddTableToTable( MapScript.DirectorOptions, waveOptions );
	if ( developer() > 0 )
		Msg( "\n*****\nMapScript.DirectorOptions:\n" );
		foreach( key, value in MapScript.DirectorOptions )
			Msg( "    " + key + " = " + value + "\n" );

		if ( LocalScript.rawin( "DirectorOptions" ) )
			Msg( "\n*****\nLocalScript.DirectorOptions:\n" );
			foreach( key, value in LocalScript.DirectorOptions )
				Msg( "    " + key + " = " + value + "\n" );


if ( Director.GetGameMode() == "coop" )
else if ( Director.GetGameMode() == "versus" ) // Allow tanks and witches in VS
	SharedOptions.ProhibitBosses = false


This script is loaded whenever an onslaught stage is started, with the exception of the first one.

  • Calls logic_timer entities to start a timer to move onto the next stage
  • The stage ends either by expired time (timer_delay_end) or gas can pour and touch (timer_delay_abort)
Msg("**Delay started**\n")

DirectorOptions <-
	MobMinSize = 2
	MobMaxSize = 3
	BoomerLimit = 0
	SmokerLimit = 0
	HunterLimit = 0
	SpitterLimit = 0
	JockeyLimit = 0
	ChargerLimit = 0
	MinimumStageTime = 15
	CommonLimit = 5

Director.ResetMobTimer() //Start the above onslaught settings immediately

// start the delay timer
EntFire( "timer_delay_end", "enable" )

//reset for this stage (PANIC and TANK depend on other factors)
DelayTouchedOrPoured   <- 0
DelayPoured            <- 0

// abort the delay if a survivor picks up or pours a gas can
// Function called by c1m4_atrium_finale.nut
function AbortDelay()
    Msg("**Delay aborted early**\n")    
    EntFire( "timer_delay_abort", "enable" )

// called by the timers themselves
// These EntFired timers MUST be the same targetname in the map!
function EndDelay()
        Msg("**Delay ended**\n") 
        EntFire( "timer_delay_end", "Disable" )
        EntFire( "timer_delay_end", "ResetTimer" )
        EntFire( "timer_delay_abort", "Disable" )
        EntFire( "timer_delay_abort", "ResetTimer" )
        EntFire( "@director", "EndCustomScriptedStage" )