How the Boulder Trap in the Chapel Works

WIP

If you’re unfamiliar, in Act 1 of BG3, just outside the Nautiloid Crashsite, there is an Ancient Temple where you find Withers. At the top of this temple are 3 NPCs who will try and stop you going inside.

Near their little camp outside, there is a boulder hanging on a rope above a cracked floor. You can shoot this rope to drop the boulder onto them, breaking the floor and opening a way into the temple.

boulderTrap ingame

Simple right? No.

Here I’m going to break down the code to show how Larian implemented this, and maybe learn a thing or two about how to add dynamic events yourself.

Setup

In the game world, there are a couple of game objects that allow this sequence to play out:

  • Boulders - These both have the same model, and are supposed to appear to be the same object

    • S_CHA_OUTSIDE_Fissure_Boulder - This boulder is hanging above the cracked floor

    • S_CHA_FL1_Fissure_Boulder - This boulder is inside the chapel.

  • Floors

    • S_CHA_FL1_FissureFloor -

  • S_CHA_Outside_HoleTrigger - A box trigger placed directly over where the hole appears

  • S_CHA_OUTSIDE_Fissure_DamageTrigger - A box trigger placed inside the hole down to the chapel.

  • S_CHA_FL1_Fissure_BoulderPartyTrigger - This is a gigantic box trigger that covers the entire lower level of the chapel. It’s used by the game to decide if to play the animation showing the boulder falling into the chapel’s lower level.

Scripting code

Let’s zoom out for a second and understand when this goal gets initialised. Recall that OSIRIS goals are executed from the top down. The boulder code is in a goal called Act1_CHA_Chapel. This follows Larian’s naming convention, with Act 1 being the region, CHA being the Chapel, and Chapel being, well, the chapel (it’s the only thing in this area).

boulder execution flow

Here we can see there are 2 parent goals above this goal. _Start is the root for everything, and checks the play has created a character. Act1 checks that the player has loaded into the Wilderness Map (the start of Act 1).

The sub goals for both these goals will not begin initialising until GoalCompleted action has been called. Once the player loads into the Wilderness Map, the INIT code section executes, and the rules defined in the KB section become active.

In OSIRIS code, game objects are referred to using their GUIDSTRING. This takes the form {Name}_{GUID}. I’ve stripped the GUIDs to make the code a bit easier to read.

Init Section

This is executed as soon as the player loads onto the map.

//REGION Boulder Puzzle (1)
DB_CHA_BoulderAttacker((CHARACTER)NULL_00000000-0000-0000-0000-000000000000);

SetOnStage(S_CHA_FL1_Fissure_Boulder, 0); (2)
SetOnStage(S_CHA_FL1_FissureFloor, 0);

TriggerRegisterForItems(S_CHA_Outside_HoleTrigger); (3)
TriggerRegisterForItems(S_CHA_OUTSIDE_Fissure_DamageTrigger);
TriggerRegisterForItems(S_CHA_FL1_Fissure_DamageTrigger);

PROC_TriggerRegisterForParty(S_CHA_FL1_Fissure_BoulderPartyTrigger); (4)

DB_CHA_Chapel_ValidBoulderDamageTypes("Slashing"); (5)
DB_CHA_Chapel_ValidBoulderDamageTypes("Piercing");
DB_CHA_Chapel_ValidBoulderDamageTypes("Bludgeoning");
DB_CHA_Chapel_ValidBoulderDamageTypes("Acid");
DB_CHA_Chapel_ValidBoulderDamageTypes("Thunder");
DB_CHA_Chapel_ValidBoulderDamageTypes("Fire");
DB_CHA_Chapel_ValidBoulderDamageTypes("Lightning");
DB_CHA_Chapel_ValidBoulderDamageTypes("Cold");
DB_CHA_Chapel_ValidBoulderDamageTypes("Force");
//END_REGION (1)
1 The REGION and END_REGION tags group lines of code together into collapsible menus.
2 This action sets objects "On Stage" or "Off Stage" stage depending on the second Boolean parameter. An offstage object cannot be seen or interacted with - it’s as if it doesn’t exist. These two objects are the boulder inside the chapel, and the cracked floor/ceiling above it. As the boulder has not yet crashed through the ceiling into the chapel, these two objects are hidden.
See SetOnStage for more.
3 When the boulder falls, it will open up a hole to the chapel. Should the player have left anything on the floor where this hole appears, we want these items to fall into the hole too. Here we set a flag that will throw an event whenever an item enters or leaves the trigger.
See TriggerRegisterForItems for more.
4 The S_CHA_FL1_Fissure_BoulderPartyTrigger is used to decide if to play the boulder falling animation. This decisions depends on if the player in the to see it. Therefore we register the trigger to send an event if a party member is in the lower level of the chapel - so we know to play the animation.
5 Here we set up the kinds of damage that can hurt the boulder. The DB_ prefixes a database. Each call of DB_CHA_Chapel_ValidBoulderDamageTypes adds a new row to this database, setting it up if it doesn’t exist. Here we essentially end up with a list of damage types.

KB (Knowledge Base) Section

Here I’ll break up the code by region. Remember, each IF …​ THEN block is called a rule. Rules only execute if every condition in their IF block is met. This first one deals with items being over the hole before it’s been created.

//REGION Before the fall
IF (1)
UseStarted(_Player, CHA_OUTSIDE_Fissure_Boulder) (2)
THEN
StartVoiceBark(CHA_Outside_VB_Boulder, _Player); (2)

IF (1)
UseStarted(_Player, S_CHA_OUTSIDE_Fissure_Crack)
THEN
StartVoiceBark(CHA_Outside_VB_Fissure, _Player);

IF (3)
ItemEnteredTrigger(_Item, S_CHA_Outside_HoleTrigger, _)
AND
Exists(_Item,1)
THEN
DB_CHA_ItemOverHole(_Item);

IF (3)
ItemLeftTrigger(_Item, S_CHA_Outside_HoleTrigger, _)
THEN
NOT DB_CHA_ItemOverHole(_Item);

IF (4)
ItemEnteredTrigger(_Item,S_CHA_OUTSIDE_Fissure_DamageTrigger, _)
AND
Exists(_Item,1)
THEN
DB_CHA_Boulder_ItemAbove(_Item);

IF (4)
ItemLeftTrigger(_Item,S_CHA_OUTSIDE_Fissure_DamageTrigger, _)
THEN
NOT DB_CHA_Boulder_ItemAbove(_Item);
//END_REGION
1 Voice Barks - If the a character interacts with either the crack in the floor or hanging boulder, they will say something.
See UseStarted for more info.
2 Notice how the _Player parameter begins with an underscore. These are OSIRIS variables, and are used to make rules more generic. Here, the UseStarted event returns a _Player and a _ITEM. We want the item to be a specific object, but we don’t really care which player clicks on it. Therefore we can catch what player did the clicking using the generic _Player, and use that variable in the bark action.
3 Recall how we set a trigger to throw an event whenever an item enters or leaves the trigger. These are the events thrown by the S_CHA_Outside_HoleTrigger. If the player drops an item inside this trigger (where the hole will appear), it will be added to a database. Similarly if it leaves the trigger, the item will be removed from the database.
4 These two events are much the same, except they’re for the S_CHA_OUTSIDE_Fissure_DamageTrigger.

This region deals with the bandits' reaction to the boulder falling.

//REGION The fall (old but needed logic)
IF
EntityEvent(S_CHA_OUTSIDE_Fissure_Boulder, "CHA_Outside_State_DebrisPillarImpact")
AND
DB_CHA_Boulder_ItemAbove(_Item)
AND
NOT DB_CHA_ItemOverHole(_Item)
THEN
PROC_CHA_DamagedByBoulder((GUIDSTRING)_Item);

IF
EntityEvent(S_CHA_FL1_Fissure_Boulder, "CHA_Outside_Event_SendToCrash")
THEN
SetFlag(CHA_FL1_State_BanditsGoToCrash, NULL_00000000-0000-0000-0000-000000000000);

IF
EntityEvent(S_CHA_FL1_Fissure_Boulder, "CHA_Outside_Event_SendToCrash")
AND
DB_CHA_InsideBandits(_Bandit, _)
AND
_Bandit != S_CHA_FL1_BanditGuard
THEN
PROC_CHA_FL1_SendToCrashPos(_Bandit);

PROC
PROC_CHA_FL1_SendToCrashPos((CHARACTER)_Bandit)
THEN
SetEntityEvent(_Bandit, "CHA_EnemyAtTheCrash", 1);
PROC_CHA_FL1_BanditForceActive(_Bandit);
PROC_SpotPlayers_StopSpotting(_Bandit, "CHA_InsideBanditSpotter");
SetCombatGroupID(_Bandit, "CHA_CorridorEncounter");

//END_REGION

This region

//REGION The NEW Fall
// Boulder directly attacked
IF
AttackedBy(S_CHA_OUTSIDE_Fissure_Boulder, _AttackOwner, _, _DamageType, _, _, _)
AND
DB_CHA_Chapel_ValidBoulderDamageTypes(_DamageType)
THEN
PROC_CHA_DestroyPillar((CHARACTER)_AttackOwner);

// Vines
IF
DestroyedBy(S_CHA_OUTSIDE_Fissure_GrapplingVines_001, _, _DestroyerOwner, _)
THEN
PROC_CHA_DestroyPillar(_DestroyerOwner);

PROC
PROC_CHA_DestroyPillar((CHARACTER)_Attacker)
AND
QRY_OnlyOnce("CHA_Boulder_AttackerAssigned")
THEN
NOT DB_CHA_BoulderAttacker(NULL_00000000-0000-0000-0000-000000000000);
DB_CHA_BoulderAttacker(_Attacker);

PROC
PROC_CHA_DestroyPillar((CHARACTER)_Attacker)
AND
QRY_OnlyOnce("CHA_PillarFall")
THEN
SetGravity(S_CHA_OUTSIDE_Fissure_Boulder, GRAVITYTYPE.Enabled);
PlaySound(S_CHA_OUTSIDE_Fissure_Boulder, "SE_S_CHA_OUTSIDE_Fissure_Boulder_Fall");

PROC
PROC_CHA_DestroyPillar((CHARACTER)_Attacker)
AND
IsDestroyed(CHA_OUTSIDE_Fissure_GrapplingVines_001, 0)
THEN
Die((ITEM)CHA_OUTSIDE_Fissure_GrapplingVines_001);

//Case the boulder falls on the platform
IF
Fell(S_CHA_OUTSIDE_Fissure_Boulder, _)
AND
IsInTrigger(S_CHA_OUTSIDE_Fissure_Boulder, S_CHA_Outside_BoulderTrigger, 1)
THEN
PROC_CHA_BoulderImpact();
DestroyPlatform(S_PLT_CHA_OUTSIDE_FissureFloor);

//Case for heavy object on top of it
IF
DualEntityEvent(_, _, "CHA_Outside_HeavyObjectOnPlatform")
THEN
DestroyPlatform(S_PLT_CHA_OUTSIDE_FissureFloor);

//Pillar aboveground animation
PROC
PROC_CHA_BoulderImpact()
THEN
SetFlag((FLAG)CHA_Outside_State_Debris_PillarFell, NULL_00000000-0000-0000-0000-000000000000, 0); // flagType: Global
PlayEffect(S_CHA_Outside_BoulderImpactFX_34ad3704-84c3-4bed-8493-a5eae5cd2a1b, (EFFECTRESOURCE)VFX_Script_Chapel_Outside_Boulder_Impact_Floor_01);
TriggerLaunchIterator(S_CHA_OUTSIDE_Fissure_DamageTrigger, "CHA_Outside_CheckDestructionAbove", "");
TriggerUnregisterForItems(S_CHA_Outside_HoleTrigger);
TriggerUnregisterForItems(S_CHA_OUTSIDE_Fissure_DamageTrigger);
TriggerUnregisterForItems(S_CHA_FL1_Fissure_DamageTrigger);
SetEntityEvent(S_CHA_OUTSIDE_Fissure_Boulder, "CHA_Outside_State_DebrisPillarImpact", 1);

IF
PlatformDestroyed(S_PLT_CHA_OUTSIDE_FissureFloor)
THEN
SetEntityEvent(S_CHA_FL1_Fissure_Boulder_000, "CHA_Outside_Event_SendToCrash", 1);
SetOnStage(S_CHA_OUTSIDE_Fissure_Crack, 0);
SetOnStage(S_CHA_FL1_FissureFloor_ShadowProxy, 0);
SetOnStage(S_CHA_FL1_FissureFloor, 1);

IF
PlatformDestroyed(S_PLT_CHA_OUTSIDE_FissureFloor)
THEN
DB_CHA_Chapel_RegisterPlatformDestroyedCrime(1);

IF
DB_CHA_Chapel_RegisterPlatformDestroyedCrime(1)
AND
DB_InRegion(_Char, S_CHA_Crypt_SUB_001)
AND
DB_PartyMembers(_Char)
AND
QRY_OnlyOnce("CHA_PlatformDestroyedCrimeRegistered")
AND
GetPosition(S_CHA_FL1_Fissure_EntranceTrigger, _X, _Y, _Z)
AND
CrimeGetNewID(_CrimeID)
THEN
NOT DB_CHA_Chapel_RegisterPlatformDestroyedCrime(1);
DB_CRIME_CrimeInvestigationPos(_CrimeID, _X, _Y, _Z);
DB_CHA_PlatformDestroyedCrime(_CrimeID);
PROC_CharacterRegisterCrimeWithPosition(_Char, "CHA_Chapel_PlatformDestroyed", NULL_00000000-0000-0000-0000-000000000000, _X, _Y, _Z, NULL_00000000-0000-0000-0000-000000000000, _CrimeID);

IF
OnCrimeInvestigatorSwitchedState(_CrimeID, _Investigator, _, "Idle")
AND
DB_CHA_PlatformDestroyedCrime(_CrimeID)
THEN
SetEntityEvent(_Investigator, "ClearPeaceReturn", 1);

PROC
PROC_CharacterRegisterCrime_Success(_, "CHA_Chapel_PlatformDestroyed", _, _, _, _CrimeID)
THEN
CrimeIgnoreCrime(_CrimeID, S_CHA_FL1_BanditGuard);

//Pillar Underground
PROC
PROC_CHA_BoulderImpact_Underground()
THEN
SetOnStage(S_CHA_FL1_Fissure_Boulder_000, 1);
PROC_CameraShakeAroundObject(S_CHA_FL1_Fissure_Boulder_000, 100, 30.0);
PROC_TriggerRegisterForPlayers(S_CHA_BanditsCrashBanter);
PROC_SetRelationToPlayers((FACTION)ACT1_CHA_GraveDiggersInside, 0);

// If no party member is in the floor where the bandits are, teleport
IF
WentOnStage(S_CHA_FL1_Fissure_Boulder_000, 1)
AND
NOT QRY_TriggerEvents_AnyPartyMemberInTrigger(S_CHA_FL1_Fissure_BoulderPartyTrigger)
THEN
TeleportTo(S_CHA_FL1_Fissure_Boulder_000, S_CHA_FL1_Fissure_TeleportBoulderTo);

IF
WentOnStage(S_CHA_FL1_Fissure_Boulder_000, 1)
THEN
PROC_TriggerUnregisterForParty(S_CHA_FL1_Fissure_BoulderPartyTrigger);

// As soon as it is set on stage destroy it
IF
WentOnStage(S_CHA_FL1_FissureFloor_5c6af29d-dce4-43c8-8192-ad4493a3297a, 1)
THEN
Die(S_CHA_FL1_FissureFloor_5c6af29d-dce4-43c8-8192-ad4493a3297a, DEATHTYPE.Physical, 0);
//END_REGION

//REGION Iterator from fall : what happens to stuffs on the path of the boulder
//If the player is underneath the falling area then the player takes damage from the collision
IF
EntityEvent(_Char, "CHA_Outside_CheckDestructionAbove")
AND
NOT DB_Dead((CHARACTER)_Char)
THEN
PROC_CHA_DamagedByBoulder((GUIDSTRING)_Char);
ObjectTimerLaunch(_Char, "CHA_Outside_CheckDeadFromPillar", 500);

IF
EntityEvent(_Char, "CHA_Outside_CheckDestructionAbove")
AND
GetFaction(_Char, _Faction)
AND
GetClosestPlayer(_Char, _Player, _)
AND
NOT DB_PartyMembers((CHARACTER)_Char)
THEN
PROC_SetHostileToIndivPlayerFaction(_Faction, _Player);

IF
ObjectTimerFinished(_Char, "CHA_Outside_CheckDeadFromPillar")
AND
DB_CHA_OutsideBandits((CHARACTER)_Char)
AND
DB_Dead(_Char)
THEN
SetFlag(CHA_Outside_State_BanditGotCrushed, NULL_00000000-0000-0000-0000-000000000000);

//If a character or an object is over the hole when the pillar fall, it end up inside the crypt
IF
EntityEvent(_Object, "CHA_Outside_CheckFallingBodies")
AND
_Object != S_CHA_OUTSIDE_Fissure_Boulder
AND
GetPosition(_Object, _ObjectX, _ObjectY, _ObjectZ)
AND
GetPosition(S_CHA_Outside_HoleTrigger, _InX, _InY, _InZ)
AND
GetPosition(S_CHA_FL1_Fissure_EntranceTrigger, _OutX, _OutY, _OutZ)
AND //Computation of: _CharPosition - _InPosition + _OutPosition
RealSum(_ObjectX, _OutX, _InterX)
AND
RealSubtract(_InterX, _InX, _EndX)
AND
RealSum(_ObjectY, _OutY, _InterY)
AND
RealSubtract(_InterY, _InY, _EndY)
AND
RealSum(_ObjectZ, _OutZ, _InterZ)
AND
RealSubtract(_InterZ, _InZ, _EndZ)
THEN
TeleportToPosition(_Object, _EndX, _EndY, _EndZ, "CHA_FalledFromPillarCrash", 0, 0, 1);

IF
EntityEvent(_Item, "CHA_FalledFromPillarCrash")
AND
DB_CHA_ItemOverHole((ITEM)_Item)
THEN
PROC_CHA_DamagedByBoulder(_Item);

PROC
PROC_CHA_DamagedByBoulder((GUIDSTRING) _Object)
AND
DB_CHA_BoulderAttacker(_Attacker)
THEN
ApplyDamage(_Object, 50, "Physical", _Attacker);
//END_REGION

This region deals with the player jumping into the hole.

//REGION Jumping into the hole the boulder made
IF
EnteredTrigger(_Char, S_CHA_OUTSIDE_ChapelJump)
AND
NOT DB_Is_InCombat(_Char, _)
THEN
TeleportTo(_Char, S_CHA_FL1_Fissure_EntranceTrigger, "", 1, 1, 1);
SetCombatGroupID(_Char, "");

IF
EnteredTrigger(_Char, S_CHA_OUTSIDE_ChapelJump)
AND
DB_Is_InCombat(_Char, _)
THEN
TeleportTo(_Char, S_CHA_FL1_Fissure_EntranceTrigger, "", 0, 0, 1);
SetCombatGroupID(_Char, "");

IF
ItemEnteredTrigger(_Item, S_CHA_OUTSIDE_ChapelJump, _)
AND
_Item != S_CHA_OUTSIDE_Fissure_Boulder
AND
Exists(_Item,1)
THEN
TeleportTo(_Item, S_CHA_FL1_Fissure_EntranceTrigger, "", 0, 0, 1);

IF
ItemEnteredTrigger(S_CHA_OUTSIDE_Fissure_Boulder, S_CHA_OUTSIDE_ChapelJump, _)
THEN
SetOnStage(S_CHA_OUTSIDE_Fissure_Boulder, 0);
PROC_CHA_BoulderImpact_Underground();
//END_REGION