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-weaponusually, and by anything else that hurts.- DotHealth
Health, armour, regeneration and spawn protection, counted in ticks so it replays.
cannot_diehurts 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.
adjustruns after every rule except the final clamp tomaximumandminimum;team_ofis how it knows who is on whose side.- DotTrace subclass
_world_rayand nothing else — the hitbox walk, the wall tie-break and the restore belong to the base class.DotTracePhysicsfor a real world,DotTraceFlatfor 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.restoreUnset, 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-nettype.- Signals
shot_resolved,shot_refused,damaged,damage_applied,healed,armour_changed,died,revived,entity_killed. Firing, reloading and ammunition signals aredot-weapon’s.
A maximum-magnitude check passes for a half-moon
DotSpread.unit() once shifted a 63-bit mixer output right by 40 and masked 24
bits, which leaves 23 — so it never returned a value above 0.5. Every shotgun
pattern was a half-moon on one side of the aim and the cone reached 1/√2 of its
stated angle. It was invisible here and obvious in
dot-2d, where the same line put every scattered
pellet in one corner of the world. If you write a scatter test, check
quadrants, not magnitude.
