dot-combat

Health, damage and hit registration for a Godot shooter — analytic hitboxes, deterministic spread, lag-compensated hit registration, and a resolver that turns any shot into damage under one set of rules.

dot-combat resolves a shot into damage: analytic hitboxes, deterministic spread and lag compensation, under one set of rules, once. A weapon decides that a shot happened; this decides what it hit.

Requires dot-core. It no longer knows what a weapon is: what a player holds and uses moved to dot-weapon, and a DotShot describes its own damage, range and splash, so a bow, a thrown charge, a trap, a turret or an environmental hazard produces one the same way a rifle does.

# Once, on the server.
var combat := DotCombatManager.new()
combat.trace = DotTracePhysics.for_world(get_world_3d())
combat.rules = DotDamageRules.strict()
add_child(combat)

# Per shootable entity.
hitbox_set.register_with(combat, player_id)
combat.register_health(player_id, health)

# Per shot. Anything can build one; dot-weapon is what usually does.
var shot := DotShot.make(&"rifle", player_id, tick, index)
shot.origin = eye_position
shot.direction = aim
shot.damage = 25.0
shot.max_range = 200.0
shot.scatter()

if is_server:
    combat.resolve_shot(shot, client_view_tick)

Whatever produced the shot runs on the owning client and on the server alike, and both reach the same shot from the same inputs, because the spread is a hash of the shot rather than a draw from a random stream.

DotCombatManager.resolve_shot() traces those shots on any peer, but applies damage only where is_authority is set. A client that resolved its own shots would be a client that decides who dies.

Three failure modes it is built around

A shot that agrees with itself. Spread from a RandomNumberGenerator gives a different pattern on the client that predicted the shot, the server that re-ran it, and every reconciliation replay. DotSpread is a pure function of (shooter, tick, shot, pellet), so all three agree.

A shot through a wall. World geometry and entity hitboxes are traced together, the world hit shortens the search, and a tie between a hitbox and a wall surface goes to the wall. A player hugging cover is not shot through it.

A rewind that leaks. Lag compensation moves every hitbox in the level into the past. Every path out of resolve_shot() restores it, including the refusal paths — a rewind that is never undone leaves the world permanently in the past, and the symptom is that shots quietly start missing for everyone.

A team-mate in the way. A shot passes through a team-mate whose hitboxes already hold its muzzle (a passenger inside a vehicle, someone standing against the shooter), because otherwise the shot hits them or is blocked at once. The server cvar mp_teammate_passthrough widens that: 0 a team-mate is hit and blocks the shot, 1 (the default) a shot passes only through one at the muzzle, 2 shots pass through every team-mate in the line. Register it with DotCombatManager.bind_cvars, which keeps DotDamageRules in step live; cvar_specs() gives the same cvars as data for a host that registers its own way. Register it with dot-server’s FLAG_REPLICATED and call follow_cvars(source) on each client’s manager, so a client predicts the shot the server will resolve. A game whose “may stand inside whom” is not a team question assigns pass_through_fn.

Registrations that are ignored say so. register_hitboxes and register_health with entity id 0 (reserved; register after the entity has a network id) or a null node used to vanish silently; they now log a warning through DotLog and are counted.

Where a game plugs in

DotShot

One thing that was fired, describing its own damage, range and splash. Built by dot-weapon usually, and by anything else that hurts.

DotHealth

Health, armour, regeneration and spawn protection, counted in ticks so it replays. cannot_die hurts without killing, for an administrator’s buddha mode.

DotDamageType

Falloff, armour share, self and friendly scales. A game adds its own.

DotHitGroup

Named body regions and their multipliers — a table, not an enum, so a game with a tail or four legs does not fork anything.

DotDamageRules + DotDamageResolver.adjustCallable

Friendly fire, self damage, hit groups, falloff and clamping, applied in one order, once. adjust runs after every rule except the final clamp to maximum and minimum; team_of is how it knows who is on whose side.

DotTrace subclass

_world_ray and nothing else — the hitbox walk, the wall tie-break and the restore belong to the base class. DotTracePhysics for a real world, DotTraceFlat for a headless one, and the fact that both exist is why the whole thing has an offline test suite.

DotCombatManager.rewind_fn / restore_fnCallable

Two assignments turn lag compensation on:

combat.rewind_fn = func(view_tick: float) -> void:
    net.history.rewind(net.registry.all(), int(view_tick), net.clock.tick)
combat.restore_fn = net.history.restore

Unset, shots resolve against the present — correct for the host’s own shots and wrong for any shooter with latency.

DotHitbox / DotHitboxSet

Capsules, boxes and spheres, tested analytically against a ray rather than through the physics server.

DotCombatNetSync

What to replicate, without naming a dot-net type.

Signals

shot_resolved, shot_refused, damaged, damage_applied, healed, armour_changed, died, revived, entity_killed. Firing, reloading and ammunition signals are dot-weapon’s.