Part of the
make-game skill. The skill links here when your agent needs the detail.3d-fps.md: hitscan and
projectile shooting, the data-driven weapon system, raycast interaction, and
the HUD.
Shooting: hitscan vs projectile
Hitscan (raycast) for rifles/pistols, projectiles for rockets/grenades. Most weapons should be hitscan - instant, cheap, reliable.Hitscan
Uses the player’s ShootRay (RayCast3D under Camera3D, target (0,0,-100), mask 6 = Environment + Enemy):force_raycast_update() matters: the ray otherwise reports last frame’s
state and fast turns produce misses. Cooldown ticks down in _process and
re-arms can_shoot.
Projectile
CharacterBody3D moved withmove_and_collide (not RigidBody - you want
deterministic flight):
Weapon system (data-driven)
WeaponData is a Resource; one .tres (or in-code instance) per weapon:weapons: Array[WeaponData],
current index, magazine/reserve ammo, and these signals for the HUD:
weapon_changed, ammo_changed(current, reserve),
reload_started(time), reload_finished.
Core rules learned the hard way:
- Automatic vs semi-auto is one branch: automatic polls
Input.is_action_pressed("shoot"), semi-auto usesis_action_just_pressed. - Fire path: refuse if
not can_shoot or is_reloading; if magazine empty, auto-start reload instead of firing; decrement ammo and emitammo_changedBEFORE resolving the hit. - Spread: randomize
shoot_ray.target_positionx/y within +-spread before the raycast, then RESET it to (0,0,-100) after - a forgotten reset makes every later weapon inherit the spread. - Switching: clear WeaponHolder children (
queue_free), instantiate the newweapon_model_scene, resetis_reloading/can_shoot/shoot_timer. Guard against re-equipping the same index. - Reload:
is_reloading guard is not optional - without it players stack reload
timers. Partial reloads take mini(needed, reserve), never a full magazine.
Weapon switching by number keys: loop weapons.size() checking
Input.is_action_just_pressed("weapon_%d" % (i + 1)).
Interaction system
Duck-typed interface - any node is interactable if it implements:look_target. Check the collider AND its parent for the methods (the
collider is often a StaticBody3D child of the actual object):
Door pattern
Node3D root that rotates; mesh + StaticBody3D child positioned at (0.5, 1.4, 0) so the hinge is the parent origin, not the door center._process lerps rotation.y toward target_rotation. Open away from the
player by checking which side they are on:
Pickup pattern
RigidBody3D withfreeze = true (physics off, still hit by rays), idle bob
(base_y + sin(bob_time) * 0.05) and slow rotate, randomized starting
phase (bob_time = randf() * TAU) so a row of pickups does not bob in
lockstep. interact() applies the effect and queue_free()s.
HUD
_process (retry until found - spawn order is not guaranteed), then
connects weapon_manager.ammo_changed. Interaction prompt shows
"[E] " + player.look_target.get_interaction_prompt() while look_target
is set, hides otherwise.
Damage feedback: on hit, set the overlay to Color(1, 0, 0, 0.3) and fade
alpha down at 2.0/s in _process. Cheap and effective.
Order of work
- Hitscan fire on the base controller (fixed 25 damage, no weapon data).
- Hit effects + damage overlay - shooting must feel real before it is configurable.
- WeaponData + WeaponManager, migrate the hardcoded fire into it, add ammo/reload + HUD ammo label.
- Second and third weapons (one automatic with spread, one projectile) to prove the data path.
- Interaction ray + door + pickup + prompt label.
Source:
library/skills/make-game/references/3d-fps-systems.md, rendered from summer-engine@4.3.0 on npm.
