Projectiles3D.bb
Client-side projectile rendering. The whole module is three functions plus one Type — total source is ~107 lines. Owns the ProjectileInstance Type (a live in-flight projectile), the per-tick UpdateProjectiles mover that walks every live projectile and frees them on impact, and the CreateProjectile(Source, Target, MeshID, Homing, Speed, ...) / FreeProjectileInstance lifecycle pair.
Sibling module Projectiles.bb owns the Projectile Type (the static template — name, mesh ID, damage, hit chance, emitter names, included on both server and client) and the LoadProjectiles / SaveProjectiles / FindProjectile file-I/O over ProjectileList(5000). Damage application, hit registration, and target validation actually live in combat code in Spells.bb / GameServer.bb, not in Projectiles.bb. This module (Projectiles3D.bb) is only the visual representation on the client.
Function-name collision: both
Projectiles.bb:14(Function CreateProjectile.Projectile()— allocates a template slot inProjectileList) andProjectiles3D.bb:11(Function CreateProjectile(Source.ActorInstance, Target.ActorInstance, MeshID, Homing, Speed#, ...)— allocates a liveProjectileInstance) declare a function namedCreateProjectile. BlitzForge resolves them by the typed-return marker on the template form vs. the untyped instance form, so both compile. When this doc says "CreateProjectile" unqualified, it means this module's instance-side function. Cross-referencing the source by name will hit two definitions — the template one is the unrelated template-allocation helper.
Type ProjectileInstance
Field Target.ActorInstance ; homing target (Null = fire-and-forget at TargetX/Y/Z)
Field TargetX#, TargetY#, TargetZ# ; resolved coordinate target when Target is Null
Field EN, EmitterEN1, EmitterEN2 ; main mesh entity + two RottParticles emitters
Field TexID1, TexID2 ; texture IDs used by the emitters (for unload bookkeeping)
Field Speed#
End TypeThe Type is allocated by CreateProjectile and Deleted by FreeProjectileInstance. There is no Dim array of projectiles — the global For Each ProjectileInstance walk in UpdateProjectiles is the only enumeration path.
The Homing argument to CreateProjectile switches between two modes:
Homing = True:P\Targetis set to the targetActorInstance. EachUpdateProjectilestick re-reads the target's currentCollisionENposition. Tracks moving targets.Homing = False:P\TargetX/Y/Zare sampled once from the target'sCollisionENat creation, andP\TargetstaysNull. The projectile flies to those frozen coordinates regardless of target movement.
The destroy check is EntityDistance#(P\EN, GPP) < 2.0 (where GPP is the global position pivot positioned at the current target each tick) — a 2-unit-radius proximity. Either mode lands within that radius; homing just re-aims toward a moving target.
Function UpdateProjectiles()
Local P.ProjectileInstance = First ProjectileInstance
Local PNext.ProjectileInstance = Null
While P <> Null
PNext = After P ; capture next BEFORE the Delete branch
; ... move, retarget, then maybe FreeProjectileInstance(P) ...
P = PNext
Wend
End FunctionThe audit-comment block at Projectiles3D.bb:62-66 records why this shape is mandatory: FreeProjectileInstance(P) calls Delete(P), and a naive For Each ... Next cursor would then dereference the freed object's next pointer on the following iteration step. The capture-After-before-Delete shape is one of the three established iterator-during-iteration fixes (CLAUDE.md → "Iterator-during-iteration hazards"). The trigger case is two projectiles landing in the same frame.
CreateProjectile allocates resources in three stages:
- Main mesh (
P\EN): looked up viaGetMesh(MeshID)ifMeshID > -1 And MeshID < 65535; scaled withLoadedMeshScales#(MeshID). If the lookup fails (template missing or out-of-range ID), falls back toCreatePivot()so the projectile is still a positionable transform — emitters and the EntityDistance check still work on an invisible pivot. - Emitter 1 (
P\EmitterEN1): created viaRP_LoadEmitterConfig("Data\Emitter Configs\<name>.rpc", Tex, Cam)+RP_CreateEmitter(Config), parented toP\EN. The texture ID is remembered inP\TexID1for laterUnloadTexture. - Emitter 2 (
P\EmitterEN2): same shape as emitter 1.
Both emitters are optional — empty Emitter1$ / Emitter2$ strings skip the allocation. The texture lookup goes through GetTexture(TexID); a failed lookup also skips the emitter (no fallback).
FreeProjectileInstance undoes all three in reverse: UnloadTexture for each TexID* that's > -1, RP_KillEmitter for each emitter that's <> 0 (re-parented to root before kill so the emitter doesn't get yanked with the parent mesh), FreeEntity(P\EN), Delete(P).
The module doesn't define globals itself but reads four from elsewhere:
Cam— the world camera handle (defined inEnvironment3D.bb). Passed toRP_LoadEmitterConfigas the billboard camera.GPP— the global position pivot allocated inClientLoaders.bb:197. Reused byUpdateProjectilesto position the target coordinate soEntityDistancecan be called againstP\EN. Each tick the homing branch overridesP\TargetX/Y/Zfrom the live target before positioningGPP.Delta#— the frame delta, used to scaleMoveEntity(P\EN, 0, 0, P\Speed# * Delta#)for framerate-independent movement.LoadedMeshScales#(MeshID)— per-template scale factor, declaredDim LoadedMeshScales#(65534)inMedia.bb:3. Indexed byActor\MeshID(or here, by the projectile'sMeshIDargument).
- All per-frame walks over
ProjectileInstancemust use the after-cursor pattern (First+After+PNextcapture). Free-current-during-walk is the most common operation; theFor Eachiterator can't survive it. The single existing site atUpdateProjectilesis the canonical example. - Texture and emitter handles are owned by the projectile. Never share a
TexIDacross projectiles without a refcount —FreeProjectileInstancewillUnloadTexturethe first projectile's texture and the second projectile will render with a stale handle. P\EN = 0is impossible — if the mesh lookup fails,CreatePivot()always succeeds (returns a non-zero handle). No null-deref guard needed downstream.- Stale
P\Targetfrom a freed actor —UpdateProjectilesreadsP\Target\CollisionENwithout aNullcheck. IfFreeActorInstanceruns while a projectile is in flight toward that actor, the next tick dereferences a freed handle. There is no current cleanup hook; a follow-up could either iterate live projectiles inFreeActorInstanceand clearTarget, or guard the deref withObject.ActorInstance(Handle(P\Target)) <> Nullper CLAUDE.md → "Handle-lookup Null discipline".
Projectiles.bb— template registry (ProjectileType — name, mesh ID, damage, emitter names, hit chance), shared between server and client. Damage application is not here; seeSpells.bb/GameServer.bb.RottParticles.bb— suppliesRP_LoadEmitterConfig/RP_CreateEmitter/RP_KillEmitter. The emitter substrate.Environment3D.bb— ownsCam(the world camera) and the entity-management primitives.Spells.bb— combat path that issues the projectile-spawn packets the client then materializes viaCreateProjectilehere.Actors.bb— declaresField CollisionENonActorInstance(Actors.bb:153).Actors3D.bbis what allocates and frees it.ClientLoaders.bb— owns theGPPglobal pivot used here.Media.bb— owns theLoadedMeshScales#(65534)Dimarray consulted byCreateProjectile.
- CLAUDE.md → "Iterator-during-iteration hazards" — the after-cursor walk pattern.
UpdateProjectilesis one of the canonical examples cited there.
The full source at src/Modules/Projectiles3D.bb is short enough that a function-by-function reference adds little. The three public functions are CreateProjectile, UpdateProjectiles, and FreeProjectileInstance.