Phase 7: netfox + godot-jolt stack upgrade

Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
@@ -0,0 +1,260 @@
#ifndef TACTICAL_SHOOTER_BITSTREAM_H
#define TACTICAL_SHOOTER_BITSTREAM_H
#include <cstdint>
#include <cstring>
#include <cassert>
#include <climits>
#include <algorithm>
#include <vector>
namespace tactical_shooter {
/**
* Bit-level read/write stream for compact network serialization.
*
* All multi-byte values are written in little-endian order regardless of
* host endianness (network byte order). Booleans pack as single bits.
* Floats can be quantized to arbitrary bit depths for bandwidth savings.
*
* Buffers are dynamically resized. Pre-allocate with reserve() to avoid
* reallocation in hot paths.
*/
class Bitstream {
public:
static constexpr size_t kMaxBufferSize = 1024 * 1024; // 1MB safety limit
Bitstream() : buffer_(), bits_written_(0), bits_read_(0) {}
explicit Bitstream(std::vector<uint8_t> data)
: buffer_(std::move(data)), bits_written_(buffer_.size() * 8), bits_read_(0) {}
// ---- Write -----------------------------------------------------------
void write_bool(bool value) {
write_bits(value ? 1 : 0, 1);
}
void write_uint8(uint8_t value) {
write_bits(value, 8);
}
void write_uint16(uint16_t value) {
write_bits(value, 16);
}
void write_uint32(uint32_t value) {
write_bits(value, 32);
}
void write_int32(int32_t value) {
// Zigzag encoding for efficient negative-number packing
uint32_t zigzag = static_cast<uint32_t>((value << 1) ^ (value >> 31));
write_bits(zigzag, 32);
}
/**
* Write a float quantized to `nbits` within [min, max].
* Storage: nbits bits. Resolution: (max-min) / (2^nbits - 1).
* Pass nbits=32 for full-precision float (no quantization loss).
*/
void write_float_quantized(float value, float min, float max, uint8_t nbits) {
assert(nbits > 0 && nbits <= 32);
if (nbits >= 32) {
// Full precision: store as raw bits
uint32_t raw;
memcpy(&raw, &value, sizeof(raw));
write_bits(raw, 32);
return;
}
float clamped = std::clamp(value, min, max);
float normalized = (clamped - min) / (max - min);
uint32_t quantized = static_cast<uint32_t>(normalized * ((1u << nbits) - 1));
write_bits(quantized, nbits);
}
/**
* Write up to `nbits` bits of `value`. LSB first packing.
*/
void write_bits(uint32_t value, uint8_t nbits) {
assert(nbits > 0 && nbits <= 32);
ensure_capacity(nbits);
uint8_t *data = buffer_.data();
size_t byte_pos = bits_written_ / 8;
uint8_t bit_offset = bits_written_ % 8;
for (uint8_t i = 0; i < nbits; ++i) {
if (value & (1u << i)) {
data[byte_pos] |= (1u << bit_offset);
}
++bit_offset;
if (bit_offset >= 8) {
bit_offset = 0;
++byte_pos;
}
}
bits_written_ += nbits;
}
// ---- Read ------------------------------------------------------------
bool read_bool() {
return read_bits(1) != 0;
}
uint8_t read_uint8() {
return static_cast<uint8_t>(read_bits(8));
}
uint16_t read_uint16() {
return static_cast<uint16_t>(read_bits(16));
}
uint32_t read_uint32() {
return read_bits(32);
}
int32_t read_int32() {
uint32_t zigzag = read_bits(32);
return static_cast<int32_t>((zigzag >> 1) ^ -(static_cast<int32_t>(zigzag & 1)));
}
/**
* Read a quantized float matching write_float_quantized().
*/
float read_float_quantized(float min, float max, uint8_t nbits) {
assert(nbits > 0 && nbits <= 32);
if (nbits >= 32) {
uint32_t raw = read_bits(32);
float value;
memcpy(&value, &raw, sizeof(value));
return value;
}
uint32_t quantized = read_bits(nbits);
float normalized = static_cast<float>(quantized) / static_cast<float>((1u << nbits) - 1);
return min + normalized * (max - min);
}
/**
* Read up to `nbits` bits, returned as LSB-packed uint32.
*/
uint32_t read_bits(uint8_t nbits) {
assert(nbits > 0 && nbits <= 32);
assert((bits_read_ + nbits) <= bits_written_);
const uint8_t *data = buffer_.data();
size_t byte_pos = bits_read_ / 8;
uint8_t bit_offset = bits_read_ % 8;
uint32_t result = 0;
for (uint8_t i = 0; i < nbits; ++i) {
if (data[byte_pos] & (1u << bit_offset)) {
result |= (1u << i);
}
++bit_offset;
if (bit_offset >= 8) {
bit_offset = 0;
++byte_pos;
}
}
bits_read_ += nbits;
return result;
}
// ---- Array helpers ---------------------------------------------------
/**
* Write a dense array of booleans packed bit-by-bit.
*/
void write_bool_array(const bool *values, size_t count) {
for (size_t i = 0; i < count; ++i) {
write_bool(values[i]);
}
}
void read_bool_array(bool *values, size_t count) {
for (size_t i = 0; i < count; ++i) {
values[i] = read_bool();
}
}
/**
* Write a variable-length array of uint8 values with a uint16 count prefix.
*/
void write_uint8_array(const uint8_t *values, uint16_t count) {
write_uint16(count);
for (uint16_t i = 0; i < count; ++i) {
write_uint8(values[i]);
}
}
std::vector<uint8_t> read_uint8_array() {
uint16_t count = read_uint16();
std::vector<uint8_t> result(count);
for (uint16_t i = 0; i < count; ++i) {
result[i] = read_uint8();
}
return result;
}
// ---- State -----------------------------------------------------------
/// Total bytes consumed by written data
size_t byte_size() const {
return (bits_written_ + 7) / 8;
}
/// Number of bits written so far
size_t bits_written() const { return bits_written_; }
/// Number of bits read so far
size_t bits_read() const { return bits_read_; }
/// Remaining readable bits
size_t bits_remaining() const {
return bits_written_ - bits_read_;
}
/// Raw buffer (const access)
const uint8_t *data() const { return buffer_.data(); }
/// Clear everything, rewind
void reset() {
buffer_.clear();
bits_written_ = 0;
bits_read_ = 0;
}
/// Pre-allocate capacity in bytes
void reserve(size_t bytes) {
buffer_.reserve(bytes);
}
/// Steal the internal buffer
std::vector<uint8_t> take_buffer() {
std::vector<uint8_t> result = std::move(buffer_);
reset();
return result;
}
private:
void ensure_capacity(uint8_t extra_bits) {
size_t needed_bytes = (bits_written_ + extra_bits + 7) / 8;
if (needed_bytes > buffer_.size()) {
if (needed_bytes > kMaxBufferSize) {
// TODO: log error instead of assert in production
assert(!"Bitstream overflow — reduce snapshot size or increase kMaxBufferSize");
}
buffer_.resize(std::max(buffer_.size() * 2, needed_bytes));
}
}
std::vector<uint8_t> buffer_;
size_t bits_written_ = 0;
size_t bits_read_ = 0;
};
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_BITSTREAM_H
@@ -0,0 +1,125 @@
#include "entity.h"
#include <algorithm>
namespace tactical_shooter {
Entity::Entity() {
reset(godot::Vector3(0, 0, 0));
}
void Entity::reset(const godot::Vector3 &spawn_position) {
position_ = spawn_position;
velocity_ = godot::Vector3(0, 0, 0);
yaw_ = 0.0f;
pitch_ = 0.0f;
health_ = 100.0f;
armor_ = 0.0f;
flags_ = ENTITY_FLAG_ALIVE;
weapon_id_ = 0;
ammo_ = 30;
last_input_ = EntityInput{};
}
void Entity::apply_input(const EntityInput &input) {
last_input_ = input;
// Update flags from input
if (input.crouch) flags_ |= ENTITY_FLAG_CROUCHING;
else flags_ &= ~ENTITY_FLAG_CROUCHING;
if (input.sprint) flags_ |= ENTITY_FLAG_SPRINTING;
else flags_ &= ~ENTITY_FLAG_SPRINTING;
if (input.aim) flags_ |= ENTITY_FLAG_AIMING;
else flags_ &= ~ENTITY_FLAG_AIMING;
}
// ---- Snapshot / Delta --------------------------------------------------------
EntitySnapshot Entity::capture_snapshot() const {
EntitySnapshot snap;
snap.entity_id = entity_id_;
snap.flags = flags_;
snap.position = position_;
snap.velocity = velocity_;
snap.yaw = yaw_;
snap.pitch = pitch_;
snap.health = health_;
snap.armor = armor_;
snap.weapon_id = weapon_id_;
snap.ammo = ammo_;
snap.last_processed_input = last_input_.input_sequence;
return snap;
}
EntitySnapshot::ChangeMask Entity::compute_change_mask(const EntitySnapshot &base) const {
uint32_t mask = EntitySnapshot::CHANGED_NONE;
if (position_ != base.position) mask |= EntitySnapshot::CHANGED_POSITION;
if (velocity_ != base.velocity) mask |= EntitySnapshot::CHANGED_VELOCITY;
if (yaw_ != base.yaw || pitch_ != base.pitch) mask |= EntitySnapshot::CHANGED_ROTATION;
if (health_ != base.health) mask |= EntitySnapshot::CHANGED_HEALTH;
if (armor_ != base.armor) mask |= EntitySnapshot::CHANGED_ARMOR;
if (weapon_id_ != base.weapon_id) mask |= EntitySnapshot::CHANGED_WEAPON;
if (ammo_ != base.ammo) mask |= EntitySnapshot::CHANGED_AMMO;
if (flags_ != base.flags) mask |= EntitySnapshot::CHANGED_FLAGS;
if (last_input_.input_sequence != base.last_processed_input)
mask |= EntitySnapshot::CHANGED_INPUT;
return static_cast<EntitySnapshot::ChangeMask>(mask);
}
// ---- GDScript Bindings -------------------------------------------------------
void Entity::_bind_methods() {
using namespace godot;
ClassDB::bind_method(D_METHOD("set_position", "position"), &Entity::set_position);
ClassDB::bind_method(D_METHOD("get_position"), &Entity::get_position);
ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "position"), "set_position", "get_position");
ClassDB::bind_method(D_METHOD("set_velocity", "velocity"), &Entity::set_velocity);
ClassDB::bind_method(D_METHOD("get_velocity"), &Entity::get_velocity);
ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "velocity"), "set_velocity", "get_velocity");
ClassDB::bind_method(D_METHOD("set_health", "health"), &Entity::set_health);
ClassDB::bind_method(D_METHOD("get_health"), &Entity::get_health);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "health"), "set_health", "get_health");
ClassDB::bind_method(D_METHOD("set_armor", "armor"), &Entity::set_armor);
ClassDB::bind_method(D_METHOD("get_armor"), &Entity::get_armor);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "armor"), "set_armor", "get_armor");
ClassDB::bind_method(D_METHOD("set_yaw", "yaw"), &Entity::set_yaw);
ClassDB::bind_method(D_METHOD("get_yaw"), &Entity::get_yaw);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "yaw"), "set_yaw", "get_yaw");
ClassDB::bind_method(D_METHOD("set_pitch", "pitch"), &Entity::set_pitch);
ClassDB::bind_method(D_METHOD("get_pitch"), &Entity::get_pitch);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "pitch"), "set_pitch", "get_pitch");
ClassDB::bind_method(D_METHOD("is_alive"), &Entity::is_alive);
ClassDB::bind_method(D_METHOD("kill"), &Entity::kill);
ClassDB::bind_method(D_METHOD("get_entity_id"), &Entity::get_entity_id);
}
// ---- Property Accessors ------------------------------------------------------
void Entity::set_position(const godot::Vector3 &p_position) { position_ = p_position; }
godot::Vector3 Entity::get_position() const { return position_; }
void Entity::set_velocity(const godot::Vector3 &p_velocity) { velocity_ = p_velocity; }
godot::Vector3 Entity::get_velocity() const { return velocity_; }
void Entity::set_health(float p_health) { health_ = std::clamp(p_health, 0.0f, 100.0f); }
float Entity::get_health() const { return health_; }
void Entity::set_armor(float p_armor) { armor_ = std::clamp(p_armor, 0.0f, 100.0f); }
float Entity::get_armor() const { return armor_; }
void Entity::set_yaw(float p_yaw) { yaw_ = p_yaw; }
float Entity::get_yaw() const { return yaw_; }
void Entity::set_pitch(float p_pitch) { pitch_ = std::clamp(p_pitch, -90.0f, 90.0f); }
float Entity::get_pitch() const { return pitch_; }
bool Entity::is_alive() const { return flags_ & ENTITY_FLAG_ALIVE; }
void Entity::kill() { flags_ &= ~ENTITY_FLAG_ALIVE; }
} // namespace tactical_shooter
@@ -0,0 +1,173 @@
#ifndef TACTICAL_SHOOTER_ENTITY_H
#define TACTICAL_SHOOTER_ENTITY_H
#include <cstdint>
#include <godot_cpp/classes/ref_counted.hpp>
#include <godot_cpp/variant/vector3.hpp>
namespace tactical_shooter {
/**
* Maximum number of entities in a single simulation.
* Hard cap prevents memory exhaustion and bounds snapshot sizes.
*/
static constexpr uint16_t kMaxEntities = 256;
/**
* Per-entity flags packed into a single uint16 for compact serialization.
*/
enum EntityFlags : uint16_t {
ENTITY_FLAG_NONE = 0,
ENTITY_FLAG_ALIVE = 1 << 0,
ENTITY_FLAG_GROUNDED = 1 << 1,
ENTITY_FLAG_CROUCHING = 1 << 2,
ENTITY_FLAG_SPRINTING = 1 << 3,
ENTITY_FLAG_AIMING = 1 << 4,
};
/**
* Entity input state for a single tick — what the client sent this frame.
* Compact enough to be sent at 128Hz.
*/
struct EntityInput {
godot::Vector3 move_direction; // normalized, quantized
float look_yaw = 0.0f; // degrees
float look_pitch = 0.0f; // degrees
bool jump = false;
bool crouch = false;
bool sprint = false;
bool shoot = false;
bool aim = false;
uint32_t input_sequence = 0; // for client-authoritative input validation
};
/**
* Full entity state snapshot at one point in time.
* Used as the "base" for delta compression — serialize only the fields
* that changed since the last acknowledged snapshot.
*/
struct EntitySnapshot {
uint16_t entity_id = 0;
uint16_t flags = ENTITY_FLAG_ALIVE;
// Transform (quantized for network)
godot::Vector3 position;
godot::Vector3 velocity;
float yaw = 0.0f; // degrees, -180..180
float pitch = 0.0f; // degrees, -90..90
// Game state
float health = 100.0f;
float armor = 0.0f;
uint8_t weapon_id = 0;
uint16_t ammo = 0;
// Client-authoritative input (for reconciliation)
uint32_t last_processed_input = 0;
/// Bit-field that tells serializers which fields changed from last base
enum ChangeMask : uint32_t {
CHANGED_NONE = 0,
CHANGED_POSITION = 1 << 0,
CHANGED_VELOCITY = 1 << 1,
CHANGED_ROTATION = 1 << 2,
CHANGED_HEALTH = 1 << 3,
CHANGED_ARMOR = 1 << 4,
CHANGED_WEAPON = 1 << 5,
CHANGED_AMMO = 1 << 6,
CHANGED_FLAGS = 1 << 7,
CHANGED_INPUT = 1 << 8,
CHANGED_ALL = 0xFFFFFFFF,
};
};
/**
* Simulation entity — internal game object managed by SimulationServer.
*
* Registered as a RefCounted so GDScript can hold references and pass
* them around without manual memory management.
*/
class Entity : public godot::RefCounted {
GDCLASS(Entity, godot::RefCounted)
public:
Entity();
~Entity() = default;
// ---- GDScript API ----------------------------------------------------
void set_position(const godot::Vector3 &p_position);
godot::Vector3 get_position() const;
void set_velocity(const godot::Vector3 &p_velocity);
godot::Vector3 get_velocity() const;
void set_health(float p_health);
float get_health() const;
void set_armor(float p_armor);
float get_armor() const;
void set_yaw(float p_yaw);
float get_yaw() const;
void set_pitch(float p_pitch);
float get_pitch() const;
bool is_alive() const;
void kill();
uint16_t get_entity_id() const { return entity_id_; }
void set_entity_id(uint16_t id) { entity_id_ = id; }
uint16_t get_flags() const { return flags_; }
void set_flags(uint16_t f) { flags_ = f; }
// ---- Internal API (not exposed to GDScript) --------------------------
/// Reset entity to spawn state
void reset(const godot::Vector3 &spawn_position);
/// Apply input for this tick (called by SimulationServer)
void apply_input(const EntityInput &input);
/// Capture current state into a snapshot
EntitySnapshot capture_snapshot() const;
/// Compute delta against a base snapshot for serialization
EntitySnapshot::ChangeMask compute_change_mask(const EntitySnapshot &base) const;
/// Internal state access (for movement/hit systems)
const EntityInput &last_input() const { return last_input_; }
float crouch_height() const { return is_crouching() ? 0.75f : 1.0f; }
protected:
static void _bind_methods();
private:
bool is_crouching() const { return flags_ & ENTITY_FLAG_CROUCHING; }
bool is_sprinting() const { return flags_ & ENTITY_FLAG_SPRINTING; }
bool is_aiming() const { return flags_ & ENTITY_FLAG_AIMING; }
uint16_t entity_id_ = 0;
uint16_t flags_ = ENTITY_FLAG_ALIVE;
// World state
godot::Vector3 position_;
godot::Vector3 velocity_;
float yaw_ = 0.0f;
float pitch_ = 0.0f;
// Gameplay
float health_ = 100.0f;
float armor_ = 0.0f;
uint8_t weapon_id_ = 0;
uint16_t ammo_ = 0;
// Input state
EntityInput last_input_;
};
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_ENTITY_H
@@ -0,0 +1,157 @@
#include "hit_detection.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace tactical_shooter {
HitDetection::HitDetection() {}
void HitDetection::set_entities(const std::vector<Entity *> &entities) {
entities_ = &entities;
}
HitResult HitDetection::raycast_entity(
const godot::Vector3 &origin,
const godot::Vector3 &direction,
float max_distance,
uint16_t exclude_id
) const {
if (!entities_) return HitResult{};
HitResult best;
best.distance = max_distance;
for (Entity *entity : *entities_) {
if (!entity || !entity->is_alive()) continue;
if (entity->get_entity_id() == exclude_id) continue;
// Simple sphere intersection test against entity bounding sphere
godot::Vector3 entity_pos = entity->get_position();
// Offset center upward for body collision (not at feet)
godot::Vector3 body_center = entity_pos;
body_center.y += kEntityHeight * 0.5f;
godot::Vector3 oc = origin - body_center;
float a = direction.dot(direction);
float b = 2.0f * oc.dot(direction);
float c = oc.dot(oc) - (kEntityRadius * kEntityRadius);
float discriminant = b * b - 4.0f * a * c;
if (discriminant < 0.0f) continue;
float t1 = (-b - std::sqrt(discriminant)) / (2.0f * a);
float t2 = (-b + std::sqrt(discriminant)) / (2.0f * a);
// Use the closest positive intersection
float t = t1;
if (t < 0.0f) t = t2;
if (t < 0.0f || t > best.distance) continue;
best.hit = true;
best.entity_id = entity->get_entity_id();
best.distance = t;
best.point = origin + direction * t;
best.normal = (best.point - body_center).normalized();
best.damage = 0.0f; // filled by process_shot
best.hitbox_id = classify_hitbox(*entity, best.point);
}
return best;
}
HitResult HitDetection::process_shot(
const godot::Vector3 &origin,
const godot::Vector3 &direction,
uint16_t shooter_id,
const WeaponDamage &weapon
) {
HitResult hit = raycast_entity(origin, direction, weapon.max_range, shooter_id);
if (hit.hit) {
float mult = get_hitbox_multiplier(hit.hitbox_id, weapon);
hit.damage = weapon.base_damage * mult;
}
return hit;
}
float HitDetection::apply_damage(Entity &entity, float damage, float mult) {
float raw = damage * mult;
// Armor absorbs a portion
float armor = entity.get_armor();
float armor_absorb = std::min(raw * 0.5f, armor);
armor -= armor_absorb;
entity.set_armor(armor);
float health_damage = raw - armor_absorb;
float new_health = entity.get_health() - health_damage;
entity.set_health(new_health);
if (new_health <= 0.0f) {
entity.kill();
}
return health_damage + armor_absorb;
}
std::vector<HitResult> HitDetection::sphere_overlap(
const godot::Vector3 &center,
float radius,
uint16_t exclude_id
) const {
std::vector<HitResult> results;
if (!entities_) return results;
float radius_sq = radius * radius;
for (Entity *entity : *entities_) {
if (!entity || !entity->is_alive()) continue;
if (entity->get_entity_id() == exclude_id) continue;
godot::Vector3 body_center = entity->get_position();
body_center.y += kEntityHeight * 0.5f;
godot::Vector3 diff = center - body_center;
float dist_sq = diff.dot(diff);
if (dist_sq <= radius_sq) {
HitResult hit;
hit.hit = true;
hit.entity_id = entity->get_entity_id();
hit.distance = std::sqrt(dist_sq);
hit.point = body_center;
hit.normal = diff.normalized();
results.push_back(hit);
}
}
return results;
}
float HitDetection::get_hitbox_multiplier(uint8_t hitbox_id, const WeaponDamage &weapon) const {
switch (hitbox_id) {
case 1: return weapon.head_multiplier;
case 2: return weapon.arm_multiplier;
case 3: return weapon.leg_multiplier;
default: return weapon.body_multiplier;
}
}
uint8_t HitDetection::classify_hitbox(
const Entity &entity,
const godot::Vector3 &hit_point
) const {
godot::Vector3 entity_pos = entity.get_position();
float relative_y = hit_point.y - entity_pos.y;
float height_ratio = relative_y / kEntityHeight;
if (height_ratio > 0.85f) return 1; // head
if (height_ratio > 0.65f) return 2; // arms/upper body
if (height_ratio > 0.25f) return 0; // body
return 3; // legs
}
} // namespace tactical_shooter
@@ -0,0 +1,138 @@
#ifndef TACTICAL_SHOOTER_HIT_DETECTION_H
#define TACTICAL_SHOOTER_HIT_DETECTION_H
#include "entity.h"
#include <cstdint>
#include <vector>
namespace tactical_shooter {
/**
* Hit detection system that operates on the simulation entities directly.
*
* Because the simulation core runs in GDExtension (not GDScript), we avoid
* crossing the script→engine boundary for every raycast during the hot loop.
* Instead, this component provides simplified geometric checks that can be
* resolved locally, or queues world-space queries for the Godot PhysicsServer3D
* if precise scene geometry is needed.
*
* Phase 1 uses sphere/box overlap against entity positions — fast, no physics
* dependency. Phase 2+ may add proper PhysicsServer3D raycasts against the
* world geometry.
*/
/**
* Result of a single hit query.
*/
struct HitResult {
bool hit = false;
uint16_t entity_id = 0xFFFF; // 0xFFFF = world geometry / no entity
float damage = 0.0f;
float distance = 0.0f;
godot::Vector3 point;
godot::Vector3 normal;
uint8_t hitbox_id = 0; // 0=body, 1=head, 2=arms, 3=legs
};
/**
* Weapon damage profile.
*/
struct WeaponDamage {
float base_damage = 30.0f;
float head_multiplier = 4.0f;
float body_multiplier = 1.0f;
float arm_multiplier = 0.75f;
float leg_multiplier = 0.6f;
float max_range = 500.0f; // units
float spread_degrees = 0.5f; // random spread per shot
};
class HitDetection {
public:
HitDetection();
/**
* Set which entities are currently in play (reference list).
* Must be called before any hit queries each frame.
*/
void set_entities(const std::vector<Entity *> &entities);
/**
* Raycast against entity bounding spheres.
* No PhysicsServer3D dependency — pure geometric check.
*
* @param origin Ray origin
* @param direction Normalized ray direction
* @param max_distance Maximum trace distance
* @param exclude_id Entity ID to exclude (shooter)
* @return First entity hit (if any)
*/
HitResult raycast_entity(
const godot::Vector3 &origin,
const godot::Vector3 &direction,
float max_distance,
uint16_t exclude_id = 0xFFFF
) const;
/**
* Process a weapon fire: apply damage to hit entity.
* Combines raycast + damage application + amortization.
*
* @param origin Fire origin
* @param direction Fire direction (with spread already applied)
* @param shooter_id Entity that fired
* @param weapon Weapon damage profile
* @return Hit result with damage
*/
HitResult process_shot(
const godot::Vector3 &origin,
const godot::Vector3 &direction,
uint16_t shooter_id,
const WeaponDamage &weapon
);
/**
* Apply damage to an entity and update its state.
* Respects armor reduction.
*
* @param entity Target entity
* @param damage Raw damage before armor
* @param mult Hitbox multiplier
* @return Actual health removed
*/
static float apply_damage(Entity &entity, float damage, float mult);
/**
* Sphere overlap detection — find all entities within radius.
* Useful for explosive damage.
*/
std::vector<HitResult> sphere_overlap(
const godot::Vector3 &center,
float radius,
uint16_t exclude_id = 0xFFFF
) const;
private:
/**
* Get the hitbox multiplier for a hit location.
*/
float get_hitbox_multiplier(uint8_t hitbox_id, const WeaponDamage &weapon) const;
/**
* Determine which hitbox a ray hit based on local-space intersection.
* Simple head/body/leg classification by vertical offset.
*/
uint8_t classify_hitbox(const Entity &entity, const godot::Vector3 &hit_point) const;
const std::vector<Entity *> *entities_ = nullptr;
// Bounding sphere radius for entity hit detection
static constexpr float kEntityRadius = 0.4f; // ~arm span / 2
static constexpr float kHeadRadius = 0.2f;
static constexpr float kEntityHeight = 1.8f; // standing height
};
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_HIT_DETECTION_H
@@ -0,0 +1,155 @@
#include "movement_component.h"
#include <algorithm>
#include <cmath>
namespace tactical_shooter {
MovementComponent::MovementComponent() {}
MovementComponent::MovementComponent(const Parameters &params)
: params_(params) {}
void MovementComponent::set_parameters(const Parameters &params) {
params_ = params;
}
void MovementComponent::update(Entity &entity, float delta) {
if (!entity.is_alive()) return;
const EntityInput &input = entity.last_input();
// Set flags based on input
uint16_t flags = entity.get_flags();
if (input.crouch) flags |= ENTITY_FLAG_CROUCHING;
else flags &= ~ENTITY_FLAG_CROUCHING;
if (input.sprint) flags |= ENTITY_FLAG_SPRINTING;
else flags &= ~ENTITY_FLAG_SPRINTING;
entity.set_flags(flags);
// Apply movement on ground vs in air
if (flags & ENTITY_FLAG_GROUNDED) {
apply_ground_movement(entity, delta);
} else {
apply_air_movement(entity, delta);
}
// Handle jump
if (input.jump && (flags & ENTITY_FLAG_GROUNDED)) {
godot::Vector3 vel = entity.get_velocity();
vel.y = params_.jump_velocity;
entity.set_velocity(vel);
entity.set_flags(flags & ~ENTITY_FLAG_GROUNDED);
}
// Apply gravity
godot::Vector3 vel = entity.get_velocity();
vel.y += params_.gravity * delta;
entity.set_velocity(vel);
// Integrate
integrate_position(entity, delta);
// Simple ground detection: if y velocity resolved, flag grounded
// (Full ground detection requires scene query — this is the stub)
vel = entity.get_velocity();
if (entity.get_position().y <= 0.0f && vel.y <= 0.0f) {
godot::Vector3 pos = entity.get_position();
pos.y = 0.0f;
entity.set_position(pos);
vel.y = 0.0f;
entity.set_velocity(vel);
uint16_t f = entity.get_flags();
f |= ENTITY_FLAG_GROUNDED;
entity.set_flags(f);
}
}
void MovementComponent::apply_ground_movement(Entity &entity, float delta) {
const EntityInput &input = entity.last_input();
godot::Vector3 vel = entity.get_velocity();
// Determine target speed
float max_speed = params_.walk_speed;
if (entity.get_flags() & ENTITY_FLAG_SPRINTING) max_speed = params_.sprint_speed;
if (entity.get_flags() & ENTITY_FLAG_CROUCHING) max_speed = params_.crouch_speed;
// Apply friction
float speed = vel.length();
if (speed > 0.0f) {
float drop = speed * params_.friction * delta;
float new_speed = std::max(0.0f, speed - drop);
vel *= (new_speed / speed);
}
// Acceleration from input
godot::Vector3 wish_dir = input.move_direction;
float wish_len = wish_dir.length();
if (wish_len > 1.0f) wish_dir /= wish_len;
if (wish_len > 0.0f) {
vel += wish_dir * params_.acceleration * delta;
// Clamp to max speed in the horizontal plane
godot::Vector3 horiz(vel.x, 0.0f, vel.z);
float horiz_len = horiz.length();
if (horiz_len > max_speed) {
horiz *= max_speed / horiz_len;
vel.x = horiz.x;
vel.z = horiz.z;
}
}
// Clamp overall velocity
if (vel.length() > params_.max_velocity) {
vel = vel.normalized() * params_.max_velocity;
}
entity.set_velocity(vel);
}
void MovementComponent::apply_air_movement(Entity &entity, float delta) {
const EntityInput &input = entity.last_input();
godot::Vector3 vel = entity.get_velocity();
// Apply air friction
float speed = vel.length();
if (speed > 0.0f) {
float drop = speed * params_.air_friction * delta;
float new_speed = std::max(0.0f, speed - drop);
vel *= (new_speed / speed);
}
// Reduced acceleration in air
godot::Vector3 wish_dir = input.move_direction;
float wish_len = wish_dir.length();
if (wish_len > 1.0f) wish_dir /= wish_len;
if (wish_len > 0.0f) {
// Only accelerate in horizontal plane while airborne
godot::Vector3 horiz_accel(
wish_dir.x * params_.air_acceleration * delta,
0.0f,
wish_dir.z * params_.air_acceleration * delta
);
vel.x += horiz_accel.x;
vel.z += horiz_accel.z;
}
// Clamp horizontal speed
godot::Vector3 horiz(vel.x, 0.0f, vel.z);
float max_horiz = std::max(params_.walk_speed, params_.sprint_speed);
if (horiz.length() > max_horiz) {
horiz *= max_horiz / horiz.length();
vel.x = horiz.x;
vel.z = horiz.z;
}
entity.set_velocity(vel);
}
void MovementComponent::integrate_position(Entity &entity, float delta) {
godot::Vector3 pos = entity.get_position();
godot::Vector3 vel = entity.get_velocity();
pos += vel * delta;
entity.set_position(pos);
}
} // namespace tactical_shooter
@@ -0,0 +1,71 @@
#ifndef TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H
#define TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H
#include "entity.h"
namespace tactical_shooter {
/**
* Physics-less movement simulation for networked FPS characters.
*
* Operates on Entity state directly — no Godot PhysicsServer3D involvement.
* Uses semi-implicit Euler integration with configurable movement parameters.
*
* All values assume "Godot units" (1 unit ≈ 1 meter) with gravity in
* units/s² and speeds in units/s.
*/
class MovementComponent {
public:
struct Parameters {
float walk_speed = 4.0f; // units/s
float sprint_speed = 6.5f; // units/s
float crouch_speed = 2.0f; // units/s
float acceleration = 20.0f; // units/s²
float air_acceleration = 4.0f; // reduced control in air
float friction = 8.0f; // ground deceleration
float air_friction = 1.0f; // air resistance
float jump_velocity = 5.0f; // initial upward velocity
float gravity = -20.0f; // units/s² (negative = downward)
float max_velocity = 10.0f; // speed cap (all directions)
float crouch_height = 0.75f; // multiplier on entity base height
float stand_height = 1.0f;
};
MovementComponent();
explicit MovementComponent(const Parameters &params);
/**
* Main update. Called once per simulation tick (e.g. every 1/128s).
*
* @param entity Entity to update
* @param delta Timestep in seconds (e.g. 0.0078125 for 128Hz)
*/
void update(Entity &entity, float delta);
/// Modify movement parameters at runtime
void set_parameters(const Parameters &params);
const Parameters &parameters() const { return params_; }
private:
/**
* Apply ground movement with acceleration/friction.
* Follows Quake/CS-style movement model (non-Strafe).
*/
void apply_ground_movement(Entity &entity, float delta);
/**
* Apply air movement with reduced acceleration.
*/
void apply_air_movement(Entity &entity, float delta);
/**
* Integrate velocity into position (semi-implicit Euler).
*/
void integrate_position(Entity &entity, float delta);
Parameters params_;
};
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H
@@ -0,0 +1,54 @@
#include "register_types.h"
#include "simulation_server.h"
#include <gdextension_interface.h>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>
namespace tactical_shooter {
void initialize_simulation_module(godot::ModuleInitializationLevel p_level) {
if (p_level != godot::MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
// Register all GDScript-facing classes
godot::ClassDB::register_class<SimulationServer>();
godot::ClassDB::register_class<Entity>();
}
void uninitialize_simulation_module(godot::ModuleInitializationLevel p_level) {
if (p_level != godot::MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
}
} // namespace tactical_shooter
extern "C" {
// GDExtension entry point — called by Godot to initialize the extension
GDExtensionBool GDE_EXPORT gdextension_entry(
GDExtensionInterfaceGetProcAddress p_get_proc_address,
GDExtensionClassLibraryPtr p_library,
GDExtensionInitialization *r_initialization
) {
godot::GDExtensionBinding::InitObject init_obj(
p_get_proc_address, p_library, r_initialization
);
init_obj.register_initializer(
tactical_shooter::initialize_simulation_module
);
init_obj.register_terminator(
tactical_shooter::uninitialize_simulation_module
);
init_obj.set_minimum_library_initialization_level(
godot::MODULE_INITIALIZATION_LEVEL_SCENE
);
return init_obj.init();
}
} // extern "C"
@@ -0,0 +1,14 @@
#ifndef TACTICAL_SHOOTER_REGISTER_TYPES_H
#define TACTICAL_SHOOTER_REGISTER_TYPES_H
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>
namespace tactical_shooter {
void initialize_simulation_module(godot::ModuleInitializationLevel p_level);
void uninitialize_simulation_module(godot::ModuleInitializationLevel p_level);
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_REGISTER_TYPES_H
@@ -0,0 +1,679 @@
#include "simulation_server.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
namespace tactical_shooter {
static constexpr float kPi = 3.14159265358979323846f;
using Clock = std::chrono::high_resolution_clock;
SimulationServer::SimulationServer()
: entities_by_id_(kMaxEntities),
queued_inputs_(kMaxEntities) {
}
SimulationServer::~SimulationServer() {
stop();
}
// ---- Lifecycle ----------------------------------------------------------------
void SimulationServer::set_tick_rate(uint32_t hz) {
tick_hz_ = std::clamp(hz, 1u, 1000u);
tick_interval_ = 1.0f / static_cast<float>(tick_hz_);
}
void SimulationServer::start() {
if (running_) return;
running_ = true;
current_tick_ = 0;
time_accumulator_ = 0.0f;
last_snapshots_.clear();
hit_detection_.set_entities(living_entity_ptrs_);
reset_stats();
weapon_config_ = WeaponDamage();
weapon_fire_rate_hz_ = 10.0f;
// Initialize position history ring buffer
position_history_.clear();
position_history_.resize(history_depth_);
history_write_index_ = 0;
for (auto &entry : position_history_) {
entry.tick = 0;
}
last_hit_result_ = LastHitResult{};
}
void SimulationServer::stop() {
running_ = false;
for (auto &entity : entities_by_id_) {
entity = godot::Ref<Entity>();
}
living_entity_ptrs_.clear();
queued_inputs_.clear();
queued_inputs_.resize(kMaxEntities);
pending_fires_.clear();
last_snapshots_.clear();
position_history_.clear();
current_tick_ = 0;
time_accumulator_ = 0.0f;
last_hit_result_ = LastHitResult{};
}
bool SimulationServer::can_tick(float delta) {
if (!running_) return false;
time_accumulator_ += delta;
return time_accumulator_ >= tick_interval_;
}
godot::PackedByteArray SimulationServer::tick() {
if (!running_) return godot::PackedByteArray();
// Drain as many fixed timestep ticks as accumulated
while (time_accumulator_ >= tick_interval_) {
time_accumulator_ -= tick_interval_;
process_tick();
}
// Serialize and return the current state
return serialize_state();
}
// ---- Entity Management -------------------------------------------------------
uint16_t SimulationServer::spawn_entity(const godot::Vector3 &position) {
uint16_t id = allocate_entity_id();
if (id >= kMaxEntities) return UINT16_MAX;
auto entity = godot::Ref<Entity>(memnew(Entity));
entity->set_entity_id(id);
entity->reset(position);
entities_by_id_[id] = entity;
// Rebuild living ptrs list
living_entity_ptrs_.clear();
for (auto &e : entities_by_id_) {
if (e.is_valid() && e->is_alive()) {
living_entity_ptrs_.push_back(e.ptr());
}
}
hit_detection_.set_entities(living_entity_ptrs_);
return id;
}
void SimulationServer::despawn_entity(uint16_t entity_id) {
if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) {
entities_by_id_[entity_id] = godot::Ref<Entity>();
last_snapshots_.erase(entity_id);
// Rebuild living ptrs
living_entity_ptrs_.clear();
for (auto &e : entities_by_id_) {
if (e.is_valid() && e->is_alive()) {
living_entity_ptrs_.push_back(e.ptr());
}
}
hit_detection_.set_entities(living_entity_ptrs_);
}
}
godot::Ref<Entity> SimulationServer::get_entity(uint16_t entity_id) {
if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) {
return entities_by_id_[entity_id];
}
return godot::Ref<Entity>();
}
uint16_t SimulationServer::get_entity_count() const {
uint16_t count = 0;
for (auto &e : entities_by_id_) {
if (e.is_valid()) ++count;
}
return count;
}
godot::Array SimulationServer::get_entity_ids() const {
godot::Array ids;
for (auto &e : entities_by_id_) {
if (e.is_valid()) {
ids.push_back(e->get_entity_id());
}
}
return ids;
}
// ---- Input --------------------------------------------------------------------
void SimulationServer::apply_input(uint16_t entity_id, const godot::Dictionary &input_dict) {
if (entity_id >= kMaxEntities) return;
if (!entities_by_id_[entity_id].is_valid()) return;
EntityInput input;
if (input_dict.has("move_direction")) {
input.move_direction = input_dict["move_direction"];
}
if (input_dict.has("look_yaw")) {
input.look_yaw = static_cast<float>(static_cast<double>(input_dict["look_yaw"]));
}
if (input_dict.has("look_pitch")) {
input.look_pitch = static_cast<float>(static_cast<double>(input_dict["look_pitch"]));
}
if (input_dict.has("jump")) {
input.jump = input_dict["jump"];
}
if (input_dict.has("crouch")) {
input.crouch = input_dict["crouch"];
}
if (input_dict.has("sprint")) {
input.sprint = input_dict["sprint"];
}
if (input_dict.has("shoot")) {
input.shoot = input_dict["shoot"];
}
if (input_dict.has("aim")) {
input.aim = input_dict["aim"];
}
if (input_dict.has("input_sequence")) {
input.input_sequence = static_cast<uint32_t>(static_cast<int64_t>(input_dict["input_sequence"]));
}
// If the shoot flag just transitioned from false→true, queue a weapon fire
// with the tick of this input so lag compensation has the right time reference.
if (input.shoot && !queued_inputs_[entity_id].input.shoot) {
PendingFire fire;
fire.shooter_id = entity_id;
fire.fire_tick = current_tick_;
fire.input_sequence = input.input_sequence;
pending_fires_.push_back(fire);
}
queued_inputs_[entity_id].input = input;
queued_inputs_[entity_id].pending = true;
}
void SimulationServer::fire_weapon(uint16_t entity_id) {
if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) {
PendingFire fire;
fire.shooter_id = entity_id;
fire.fire_tick = current_tick_;
// Use the input sequence from the shooter's last input if available
if (entities_by_id_[entity_id].is_valid()) {
fire.input_sequence = entities_by_id_[entity_id]->last_input().input_sequence;
}
pending_fires_.push_back(fire);
}
}
// ---- Movement Configuration --------------------------------------------------
void SimulationServer::set_movement_walk_speed(float speed) {
auto p = movement_.parameters();
p.walk_speed = speed;
movement_.set_parameters(p);
}
void SimulationServer::set_movement_sprint_speed(float speed) {
auto p = movement_.parameters();
p.sprint_speed = speed;
movement_.set_parameters(p);
}
void SimulationServer::set_movement_crouch_speed(float speed) {
auto p = movement_.parameters();
p.crouch_speed = speed;
movement_.set_parameters(p);
}
void SimulationServer::set_movement_acceleration(float accel) {
auto p = movement_.parameters();
p.acceleration = accel;
movement_.set_parameters(p);
}
void SimulationServer::set_movement_jump_velocity(float vel) {
auto p = movement_.parameters();
p.jump_velocity = vel;
movement_.set_parameters(p);
}
void SimulationServer::set_movement_gravity(float gravity) {
auto p = movement_.parameters();
p.gravity = gravity;
movement_.set_parameters(p);
}
void SimulationServer::set_movement_config(const godot::Dictionary &config) {
auto p = movement_.parameters();
if (config.has("walk_speed")) p.walk_speed = static_cast<float>(static_cast<double>(config["walk_speed"]));
if (config.has("sprint_speed")) p.sprint_speed = static_cast<float>(static_cast<double>(config["sprint_speed"]));
if (config.has("crouch_speed")) p.crouch_speed = static_cast<float>(static_cast<double>(config["crouch_speed"]));
if (config.has("acceleration")) p.acceleration = static_cast<float>(static_cast<double>(config["acceleration"]));
if (config.has("air_acceleration")) p.air_acceleration = static_cast<float>(static_cast<double>(config["air_acceleration"]));
if (config.has("jump_velocity")) p.jump_velocity = static_cast<float>(static_cast<double>(config["jump_velocity"]));
if (config.has("gravity")) p.gravity = static_cast<float>(static_cast<double>(config["gravity"]));
if (config.has("friction")) p.friction = static_cast<float>(static_cast<double>(config["friction"]));
if (config.has("max_velocity")) p.max_velocity = static_cast<float>(static_cast<double>(config["max_velocity"]));
movement_.set_parameters(p);
}
// ---- Benchmark / Stats -------------------------------------------------------
godot::Dictionary SimulationServer::get_stats() const {
godot::Dictionary stats;
stats["last_tick_usec"] = static_cast<int64_t>(last_tick_usec_);
stats["tick_count"] = static_cast<int64_t>(tick_count_);
stats["entity_count"] = static_cast<int64_t>(get_entity_count());
float avg_usec = 0.0f;
if (tick_count_ > 0) {
avg_usec = static_cast<float>(total_tick_usec_) / static_cast<float>(tick_count_);
}
stats["avg_tick_usec"] = avg_usec;
stats["peak_tick_usec"] = static_cast<float>(max_tick_usec_);
// Also expose tick interval
stats["tick_hz"] = static_cast<int64_t>(tick_hz_);
stats["tick_interval_usec"] = static_cast<int64_t>(tick_interval_ * 1'000'000.0f);
return stats;
}
void SimulationServer::reset_stats() {
tick_count_ = 0;
last_tick_usec_ = 0;
total_tick_usec_ = 0;
max_tick_usec_ = 0;
}
void SimulationServer::populate_bots(uint16_t count) {
count = std::min(count, static_cast<uint16_t>(kMaxEntities));
// Clear existing
for (auto &e : entities_by_id_) {
e = godot::Ref<Entity>();
}
last_snapshots_.clear();
// Spawn bots in a grid pattern
uint16_t per_row = static_cast<uint16_t>(std::ceil(std::sqrt(static_cast<float>(count))));
for (uint16_t i = 0; i < count; ++i) {
uint16_t row = i / per_row;
uint16_t col = i % per_row;
godot::Vector3 pos(
static_cast<float>(col) * 3.0f,
0.0f,
static_cast<float>(row) * 3.0f
);
spawn_entity(pos);
}
}
// ---- Private: Tick Logic -----------------------------------------------------
void SimulationServer::process_tick() {
auto tick_start = Clock::now();
// 1. Apply queued inputs from clients
for (uint16_t i = 0; i < kMaxEntities; ++i) {
if (queued_inputs_[i].pending && entities_by_id_[i].is_valid()) {
entities_by_id_[i]->apply_input(queued_inputs_[i].input);
queued_inputs_[i].pending = false;
}
}
// 2. Record pre-move positions for lag compensation (BEFORE movement)
record_position_history(current_tick_);
// 3. Update entity positions
update_movement();
// 4. Process combat with lag compensation
update_combat();
// 5. Process queued weapon fires from fire_weapon() calls (deprecated path,
// kept for backwards compat — modern path queues fires via apply_input)
// This is now handled inside update_combat() which drains pending_fires_
// 6. Clean up dead entities after combat
for (auto &entity : entities_by_id_) {
if (entity.is_valid() && !entity->is_alive()) {
// Keep dead entities in list for ragdoll/corpse but mark
// We could despawn here if needed
}
}
// 7. Rebuild living entity ptrs for hit detection next tick
living_entity_ptrs_.clear();
for (auto &e : entities_by_id_) {
if (e.is_valid() && e->is_alive()) {
living_entity_ptrs_.push_back(e.ptr());
}
}
hit_detection_.set_entities(living_entity_ptrs_);
++current_tick_;
++tick_count_;
// Timing
auto tick_end = Clock::now();
last_tick_usec_ = std::chrono::duration_cast<std::chrono::microseconds>(
tick_end - tick_start
).count();
total_tick_usec_ += last_tick_usec_;
max_tick_usec_ = std::max(max_tick_usec_, last_tick_usec_);
}
void SimulationServer::update_movement() {
float delta = tick_interval_;
for (auto &entity_ref : living_entity_ptrs_) {
movement_.update(*entity_ref, delta);
}
}
void SimulationServer::update_combat() {
if (pending_fires_.empty()) return;
// Sort fires by fire_tick (oldest first) for deterministic processing
std::sort(pending_fires_.begin(), pending_fires_.end(),
[](const PendingFire &a, const PendingFire &b) {
return a.fire_tick < b.fire_tick;
});
// Process each fire with lag compensation
for (auto &fire : pending_fires_) {
process_compensated_fire(fire);
}
pending_fires_.clear();
}
// ---- Lag Compensation ---------------------------------------------------------
void SimulationServer::record_position_history(uint32_t tick) {
TickPositionSnapshot &slot = position_history_[history_write_index_];
slot.tick = tick;
slot.entries.clear();
for (Entity *entity : living_entity_ptrs_) {
if (!entity) continue;
TickPositionSnapshot::Entry entry;
entry.entity_id = entity->get_entity_id();
entry.position = entity->get_position();
entry.flags = entity->get_flags();
slot.entries.push_back(entry);
}
history_write_index_ = (history_write_index_ + 1) % history_depth_;
}
SimulationServer::RewindState SimulationServer::begin_rewind(uint32_t target_tick) {
RewindState state;
// Find the closest position snapshot at or before the target tick
// Search backward from the write index
for (uint32_t i = 0; i < history_depth_; ++i) {
uint32_t idx = (history_write_index_ + history_depth_ - 1 - i) % history_depth_;
const TickPositionSnapshot &snap = position_history_[idx];
if (snap.tick == 0) break; // uninitialized slot
if (snap.tick <= target_tick) {
// Save current positions, then rewind to snapshot positions
for (auto &entry : snap.entries) {
if (entry.entity_id < kMaxEntities &&
entities_by_id_[entry.entity_id].is_valid()) {
// Save current position
TickPositionSnapshot::Entry saved;
saved.entity_id = entry.entity_id;
saved.position = entities_by_id_[entry.entity_id]->get_position();
saved.flags = entities_by_id_[entry.entity_id]->get_flags();
state.saved.push_back(saved);
// Rewind to snapshot position
entities_by_id_[entry.entity_id]->set_position(entry.position);
entities_by_id_[entry.entity_id]->set_flags(entry.flags);
}
}
break;
}
}
// Update hit detection with current (now rewound) positions
// The living_entity_ptrs_ haven't changed, only positions have
return state;
}
void SimulationServer::end_rewind(const RewindState &state) {
// Restore all saved positions
for (auto &saved : state.saved) {
if (saved.entity_id < kMaxEntities &&
entities_by_id_[saved.entity_id].is_valid()) {
entities_by_id_[saved.entity_id]->set_position(saved.position);
entities_by_id_[saved.entity_id]->set_flags(saved.flags);
}
}
}
void SimulationServer::process_compensated_fire(const PendingFire &fire) {
auto &shooter = entities_by_id_[fire.shooter_id];
if (!shooter.is_valid() || !shooter->is_alive()) return;
// Calculate fire direction from shooter's yaw/pitch at time of fire
float yaw_rad = shooter->get_yaw() * (kPi / 180.0f);
float pitch_rad = shooter->get_pitch() * (kPi / 180.0f);
godot::Vector3 direction(
std::cos(pitch_rad) * std::sin(yaw_rad),
-std::sin(pitch_rad),
std::cos(pitch_rad) * std::cos(yaw_rad)
);
// Apply spread (random cone)
if (weapon_config_.spread_degrees > 0.0f) {
float spread_rad = weapon_config_.spread_degrees * (kPi / 180.0f);
float theta = static_cast<float>(rand() % 360) * (kPi / 180.0f);
float phi = static_cast<float>(rand() % 100) / 100.0f * spread_rad;
godot::Vector3 up(0.0f, 1.0f, 0.0f);
godot::Vector3 right = direction.cross(up).normalized();
if (right.length_squared() < 0.001f) {
right = godot::Vector3(1.0f, 0.0f, 0.0f);
}
up = right.cross(direction).normalized();
godot::Vector3 spread_offset = right * std::sin(theta) * std::sin(phi)
+ up * std::cos(theta) * std::sin(phi);
direction = (direction + spread_offset).normalized();
}
// Shooter's eye position (current — not rewound)
godot::Vector3 origin = shooter->get_position();
origin.y += 1.5f; // eye height
// BEGIN LAG COMPENSATION: rewind targets to fire_tick positions
RewindState rewind_state = begin_rewind(fire.fire_tick);
// Restore shooter to current position immediately (don't rewind the shooter)
for (auto it = rewind_state.saved.begin(); it != rewind_state.saved.end(); ) {
if (it->entity_id == fire.shooter_id) {
// Put shooter back to current position
entities_by_id_[fire.shooter_id]->set_position(it->position);
entities_by_id_[fire.shooter_id]->set_flags(it->flags);
// Remove from rewind set so end_rewind doesn't re-restore it
it = rewind_state.saved.erase(it);
} else {
++it;
}
}
// Update hit detection reference (pointers are still valid, positions changed)
hit_detection_.set_entities(living_entity_ptrs_);
// Fire the shot against rewound target positions
HitResult hit = hit_detection_.process_shot(
origin, direction, fire.shooter_id, weapon_config_
);
// END LAG COMPENSATION: restore all entity positions
end_rewind(rewind_state);
hit_detection_.set_entities(living_entity_ptrs_);
// Apply damage if hit
if (hit.hit && hit.entity_id < kMaxEntities) {
auto &target_ref = entities_by_id_[hit.entity_id];
if (target_ref.is_valid()) {
Entity *target_ptr = target_ref.ptr();
HitDetection::apply_damage(*target_ptr, hit.damage, 1.0f);
// Record hit result for GDScript feedback
last_hit_result_.hit = true;
last_hit_result_.entity_id = hit.entity_id;
last_hit_result_.damage = hit.damage;
last_hit_result_.distance = hit.distance;
last_hit_result_.hitbox_id = hit.hitbox_id;
last_hit_result_.killed = !target_ptr->is_alive();
}
} else {
last_hit_result_.hit = false;
}
}
// ---- Weapon Config ------------------------------------------------------------
void SimulationServer::set_weapon_config(const godot::Dictionary &config) {
WeaponDamage wd;
if (config.has("base_damage")) wd.base_damage = static_cast<float>(static_cast<double>(config["base_damage"]));
if (config.has("head_multiplier")) wd.head_multiplier = static_cast<float>(static_cast<double>(config["head_multiplier"]));
if (config.has("body_multiplier")) wd.body_multiplier = static_cast<float>(static_cast<double>(config["body_multiplier"]));
if (config.has("arm_multiplier")) wd.arm_multiplier = static_cast<float>(static_cast<double>(config["arm_multiplier"]));
if (config.has("leg_multiplier")) wd.leg_multiplier = static_cast<float>(static_cast<double>(config["leg_multiplier"]));
if (config.has("max_range")) wd.max_range = static_cast<float>(static_cast<double>(config["max_range"]));
if (config.has("spread_degrees")) wd.spread_degrees = static_cast<float>(static_cast<double>(config["spread_degrees"]));
if (config.has("fire_rate_hz")) weapon_fire_rate_hz_ = static_cast<float>(static_cast<double>(config["fire_rate_hz"]));
weapon_config_ = wd;
}
void SimulationServer::set_history_depth(uint32_t ticks) {
history_depth_ = std::clamp(ticks, 16u, 256u);
}
godot::Dictionary SimulationServer::get_last_hit_result() const {
godot::Dictionary result;
result["hit"] = last_hit_result_.hit;
result["entity_id"] = static_cast<int64_t>(last_hit_result_.entity_id);
result["damage"] = last_hit_result_.damage;
result["distance"] = last_hit_result_.distance;
result["hitbox_id"] = static_cast<int64_t>(last_hit_result_.hitbox_id);
result["killed"] = last_hit_result_.killed;
return result;
}
godot::PackedByteArray SimulationServer::serialize_state() {
const uint16_t count = static_cast<uint16_t>(living_entity_ptrs_.size());
if (count == 0) return godot::PackedByteArray();
// Pre-allocate a reasonable buffer
Bitstream stream;
stream.reserve(1024); // 1KB should cover a full snapshot of 32 entities
// Use delta compression if we have a base snapshot
if (!last_snapshots_.empty()) {
serializer_.write_delta_snapshot(
stream, current_tick_,
living_entity_ptrs_.data(), count,
last_snapshots_
);
} else {
serializer_.write_full_snapshot(
stream, current_tick_,
living_entity_ptrs_.data(), count
);
}
// Update last snapshots for next delta
for (Entity *entity : living_entity_ptrs_) {
if (entity) {
last_snapshots_[entity->get_entity_id()] = entity->capture_snapshot();
}
}
// Convert to PackedByteArray
godot::PackedByteArray result;
size_t byte_size = stream.byte_size();
result.resize(static_cast<int64_t>(byte_size));
if (byte_size > 0) {
uint8_t *dst = result.ptrw();
memcpy(dst, stream.data(), byte_size);
}
return result;
}
// ---- Private: Helpers --------------------------------------------------------
uint16_t SimulationServer::allocate_entity_id() {
for (uint16_t i = 0; i < kMaxEntities; ++i) {
if (!entities_by_id_[i].is_valid()) {
return i;
}
}
return UINT16_MAX;
}
// ---- GDScript Bindings -------------------------------------------------------
void SimulationServer::_bind_methods() {
using namespace godot;
// Lifecycle
ClassDB::bind_method(D_METHOD("set_tick_rate", "hz"), &SimulationServer::set_tick_rate);
ClassDB::bind_method(D_METHOD("get_tick_rate"), &SimulationServer::get_tick_rate);
ADD_PROPERTY(PropertyInfo(Variant::INT, "tick_rate"), "set_tick_rate", "get_tick_rate");
ClassDB::bind_method(D_METHOD("start"), &SimulationServer::start);
ClassDB::bind_method(D_METHOD("stop"), &SimulationServer::stop);
ClassDB::bind_method(D_METHOD("is_running"), &SimulationServer::is_running);
ClassDB::bind_method(D_METHOD("can_tick", "delta"), &SimulationServer::can_tick);
ClassDB::bind_method(D_METHOD("tick"), &SimulationServer::tick);
// Entity management
ClassDB::bind_method(D_METHOD("spawn_entity", "position"), &SimulationServer::spawn_entity);
ClassDB::bind_method(D_METHOD("despawn_entity", "entity_id"), &SimulationServer::despawn_entity);
ClassDB::bind_method(D_METHOD("get_entity", "entity_id"), &SimulationServer::get_entity);
ClassDB::bind_method(D_METHOD("get_entity_count"), &SimulationServer::get_entity_count);
ClassDB::bind_method(D_METHOD("get_entity_ids"), &SimulationServer::get_entity_ids);
// Input
ClassDB::bind_method(D_METHOD("apply_input", "entity_id", "input_dict"), &SimulationServer::apply_input);
ClassDB::bind_method(D_METHOD("fire_weapon", "entity_id"), &SimulationServer::fire_weapon);
// Movement config
ClassDB::bind_method(D_METHOD("set_movement_walk_speed", "speed"), &SimulationServer::set_movement_walk_speed);
ClassDB::bind_method(D_METHOD("set_movement_sprint_speed", "speed"), &SimulationServer::set_movement_sprint_speed);
ClassDB::bind_method(D_METHOD("set_movement_crouch_speed", "speed"), &SimulationServer::set_movement_crouch_speed);
ClassDB::bind_method(D_METHOD("set_movement_acceleration", "accel"), &SimulationServer::set_movement_acceleration);
ClassDB::bind_method(D_METHOD("set_movement_jump_velocity", "vel"), &SimulationServer::set_movement_jump_velocity);
ClassDB::bind_method(D_METHOD("set_movement_gravity", "gravity"), &SimulationServer::set_movement_gravity);
ClassDB::bind_method(D_METHOD("set_movement_config", "config"), &SimulationServer::set_movement_config);
// Weapon config
ClassDB::bind_method(D_METHOD("set_weapon_config", "config"), &SimulationServer::set_weapon_config);
// Lag compensation
ClassDB::bind_method(D_METHOD("set_history_depth", "ticks"), &SimulationServer::set_history_depth);
// Hit feedback
ClassDB::bind_method(D_METHOD("get_last_hit_result"), &SimulationServer::get_last_hit_result);
// Benchmark
ClassDB::bind_method(D_METHOD("get_stats"), &SimulationServer::get_stats);
ClassDB::bind_method(D_METHOD("reset_stats"), &SimulationServer::reset_stats);
ClassDB::bind_method(D_METHOD("populate_bots", "count"), &SimulationServer::populate_bots);
}
} // namespace tactical_shooter
@@ -0,0 +1,347 @@
#ifndef TACTICAL_SHOOTER_SIMULATION_SERVER_H
#define TACTICAL_SHOOTER_SIMULATION_SERVER_H
#include "entity.h"
#include "hit_detection.h"
#include "movement_component.h"
#include "state_serializer.h"
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <array>
#include <cstdint>
#include <unordered_map>
#include <vector>
namespace tactical_shooter {
/**
* Position history ring-buffer entry — stores entity positions at a given tick.
* Used for lag compensation: we can rewind targets to where they were when a
* shot was fired, even if they've since moved.
*/
struct TickPositionSnapshot {
uint32_t tick = 0;
struct Entry {
uint16_t entity_id;
godot::Vector3 position;
uint16_t flags;
};
std::vector<Entry> entries;
};
/**
* A pending weapon fire with time-of-fire information for lag compensation.
*/
struct PendingFire {
uint16_t shooter_id = 0xFFFF;
uint32_t fire_tick = 0; // Server tick at which the fire was queued
uint32_t input_sequence = 0; // Client's input sequence (for reconciliation)
};
/**
* SimulationServer — the heart of the tactical shooter simulation.
*
* This singleton manages the entire 128Hz game simulation loop from C++,
* entirely avoiding GDScript VM overhead in the hot path.
*
* GDScript integration:
* var server = SimulationServer.new()
* server.set_tick_rate(128)
* server.start()
* # In _process():
* while server.can_tick(delta):
* var serialized = server.tick()
* # send serialized to network layer
*
* Architecture:
* - All entity simulation (movement, hit detection) runs in C++
* - Serialized state is handed to GDScript for network transport
* - Player input arrives from GDScript, gets applied per-entity
* - Lag compensation stores a ring buffer of pre-move positions each tick
* - Benchmark hooks for the 128Hz load test (t_f671f48a)
*/
class SimulationServer : public godot::Object {
GDCLASS(SimulationServer, godot::Object)
public:
SimulationServer();
~SimulationServer();
// ---- Lifecycle (GDScript API) ------------------------------------------
/**
* Set the simulation tick rate (calls per second).
* Default: 128 (for 128Hz tick).
*/
void set_tick_rate(uint32_t hz);
uint32_t get_tick_rate() const { return tick_hz_; }
/**
* Start the simulation. Creates initial entities if count > 0.
*/
void start();
/**
* Stop the simulation. Clears all entities and resets state.
*/
void stop();
/**
* Check if the simulation is running.
*/
bool is_running() const { return running_; }
/**
* Accrue time and check if a tick is due.
* Call this in _process(delta) and call tick() while this returns true.
*
* Example (GDScript):
* while server.can_tick(delta):
* input = server.tick()
*/
bool can_tick(float delta);
/**
* Advance one simulation tick.
* Returns serialized snapshot as a PackedByteArray (or empty if no tick due).
*/
godot::PackedByteArray tick();
// ---- Entity Management (GDScript API) -----------------------------------
/**
* Spawn a new entity at the given position.
* Returns the entity ID (0..65535) or UINT16_MAX on failure.
*/
uint16_t spawn_entity(const godot::Vector3 &position);
/**
* Despawn an entity by ID.
*/
void despawn_entity(uint16_t entity_id);
/**
* Get an entity by ID. Returns null if not found.
*/
godot::Ref<Entity> get_entity(uint16_t entity_id);
/**
* Get number of active entities.
*/
uint16_t get_entity_count() const;
/**
* Get entity IDs of all active entities as an array.
*/
godot::Array get_entity_ids() const;
// ---- Input (GDScript API) -----------------------------------------------
/**
* Apply input for a specific entity on the next tick.
* Call this from _process() as input arrives.
*
* @param entity_id Target entity
* @param input_dict Dictionary with keys: "move_direction" (Vector3),
* "look_yaw" (float), "look_pitch" (float), "jump" (bool),
* "crouch" (bool), "sprint" (bool), "shoot" (bool), "aim" (bool),
* "input_sequence" (int)
*/
void apply_input(uint16_t entity_id, const godot::Dictionary &input_dict);
/**
* Queue a weapon fire from an entity.
* Fires on the next tick.
*/
void fire_weapon(uint16_t entity_id);
// ---- Movement Configuration (GDScript API) -------------------------------
void set_movement_walk_speed(float speed);
void set_movement_sprint_speed(float speed);
void set_movement_crouch_speed(float speed);
void set_movement_acceleration(float accel);
void set_movement_jump_velocity(float vel);
void set_movement_gravity(float gravity);
void set_movement_config(const godot::Dictionary &config);
// ---- Weapon Configuration (GDScript API) ----------------------------------
/**
* Set the weapon damage profile for hitscan weapons.
* Called from GDScript to define weapon stats.
*
* Dictionary keys:
* base_damage (float) — base damage per shot
* head_multiplier (float) — damage multiplier for head hits
* body_multiplier (float) — damage multiplier for body hits
* arm_multiplier (float) — damage multiplier for arm hits
* leg_multiplier (float) — damage multiplier for leg hits
* max_range (float) — maximum shot distance in units
* spread_degrees (float) — random spread per shot (degrees)
* fire_rate_hz (float) — max shots per second
*/
void set_weapon_config(const godot::Dictionary &config);
// ---- Lag Compensation Configuration (GDScript API) -------------------------
/**
* Set the depth of the position history ring buffer (in ticks).
* More ticks = can compensate for higher latency but uses more memory.
* Default: 64 ticks (~500ms at 128Hz, enough for any reasonable ping).
*/
void set_history_depth(uint32_t ticks);
// ---- Damaged / Death Signal (GDScript API via Dictionary return) -----------
/**
* Get the most recent hit result from processing combat.
* Returns Dictionary with: hit (bool), entity_id (int), damage (float),
* distance (float), hitbox_id (int), killed (bool).
* Empty if no shot was processed this tick.
*/
godot::Dictionary get_last_hit_result() const;
// ---- Benchmark / Stats (GDScript API) ------------------------------------
/**
* Get tick timing statistics for benchmarking.
* Returns Dictionary with: "last_tick_usec" (int), "avg_tick_usec" (float),
* "peak_tick_usec" (float), "tick_count" (int), "entity_count" (int)
*/
godot::Dictionary get_stats() const;
/**
* Reset benchmark statistics.
*/
void reset_stats();
/**
* Populate simulation with N bots for load testing.
* Places them in a grid pattern.
*/
void populate_bots(uint16_t count);
protected:
static void _bind_methods();
private:
// ---- Internal tick logic ------------------------------------------------
/**
* Advance the simulation by one tick (fixed timestep).
*/
void process_tick();
/**
* Update all entity movement.
*/
void update_movement();
/**
* Process all queued weapon fires.
*/
void update_combat();
/**
* Collect and serialize the current simulation state.
*/
godot::PackedByteArray serialize_state();
// ---- Helpers ------------------------------------------------------------
/**
* Find the lowest available entity ID.
*/
uint16_t allocate_entity_id();
// ---- Lag Compensation (Position History) ----------------------------------
/**
* Record pre-move positions of all living entities for the given tick.
* Called at the start of process_tick(), BEFORE update_movement().
*/
void record_position_history(uint32_t tick);
/**
* Rewind target entities to positions stored at a given tick for hit
* detection, then restore them back.
*/
struct RewindState {
std::vector<TickPositionSnapshot::Entry> saved;
};
RewindState begin_rewind(uint32_t target_tick);
void end_rewind(const RewindState &state);
/**
* Process a single compensated fire: rewind, check hit, restore.
*/
void process_compensated_fire(const PendingFire &fire);
// ---- State --------------------------------------------------------------
bool running_ = false;
uint32_t tick_hz_ = 128;
float tick_interval_ = 1.0f / 128.0f;
float time_accumulator_ = 0.0f;
// Tick counter
uint32_t current_tick_ = 0;
// Entity storage (by ID)
std::vector<godot::Ref<Entity>> entities_by_id_;
std::vector<Entity *> living_entity_ptrs_; // raw ptrs for hot loop
// Systems
MovementComponent movement_;
HitDetection hit_detection_;
StateSerializer serializer_;
// Queued input (applied on next tick, cleared after)
struct QueuedInput {
EntityInput input;
bool pending = false;
};
std::vector<QueuedInput> queued_inputs_;
// Queued weapon fires with lag-compensation info
std::vector<PendingFire> pending_fires_;
WeaponDamage weapon_config_;
float weapon_fire_rate_hz_ = 10.0f;
// Last hit result (queried by GDScript for feedback)
struct LastHitResult {
bool hit = false;
uint16_t entity_id = 0xFFFF;
float damage = 0.0f;
float distance = 0.0f;
uint8_t hitbox_id = 0;
bool killed = false;
};
LastHitResult last_hit_result_;
// Position history ring buffer (for lag compensation)
std::vector<TickPositionSnapshot> position_history_;
uint32_t history_depth_ = 64;
uint32_t history_write_index_ = 0;
// Last serialized snapshot per entity (for delta compression)
std::unordered_map<uint16_t, EntitySnapshot> last_snapshots_;
// Benchmark stats
uint64_t tick_count_ = 0;
uint64_t last_tick_usec_ = 0;
uint64_t total_tick_usec_ = 0;
uint64_t max_tick_usec_ = 0;
};
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_SIMULATION_SERVER_H
@@ -0,0 +1,262 @@
#include "state_serializer.h"
#include <cassert>
namespace tactical_shooter {
StateSerializer::StateSerializer(const SerializationConfig &config)
: config_(config) {}
// ---- Write Full --------------------------------------------------------------
void StateSerializer::write_full_snapshot(
Bitstream &stream,
uint32_t tick,
const Entity *const *entities,
uint16_t count
) {
// Header: tick, entity count, base_tick=0 (full)
stream.write_uint32(tick);
stream.write_uint16(count);
stream.write_uint16(0); // base_tick=0 signals "full snapshot"
for (uint16_t i = 0; i < count; ++i) {
if (entities[i] && entities[i]->is_alive()) {
write_entity_full(stream, *entities[i]);
}
}
}
// ---- Write Delta -------------------------------------------------------------
void StateSerializer::write_delta_snapshot(
Bitstream &stream,
uint32_t tick,
const Entity *const *entities,
uint16_t count,
const std::unordered_map<uint16_t, EntitySnapshot> &base
) {
// Count changed entities first
uint16_t changed_count = 0;
for (uint16_t i = 0; i < count; ++i) {
if (!entities[i] || !entities[i]->is_alive()) continue;
uint16_t id = entities[i]->get_entity_id();
auto it = base.find(id);
if (it == base.end()) {
++changed_count; // new entity = full
} else {
EntitySnapshot::ChangeMask mask =
entities[i]->compute_change_mask(it->second);
if (mask != EntitySnapshot::CHANGED_NONE) {
++changed_count;
}
}
}
// Header: tick, count, delta_tick
stream.write_uint32(tick);
stream.write_uint16(changed_count);
// We need the tick of the base snapshot — for now write 0 (delta from
// client's last acked tick which the client tracks itself).
stream.write_uint16(0);
// Write delta entities
for (uint16_t i = 0; i < count; ++i) {
if (!entities[i] || !entities[i]->is_alive()) continue;
uint16_t id = entities[i]->get_entity_id();
auto it = base.find(id);
if (it == base.end()) {
// New entity: write full state with CHANGED_ALL
stream.write_uint16(id);
stream.write_uint32(EntitySnapshot::CHANGED_ALL);
write_entity_full(stream, *entities[i]);
} else {
EntitySnapshot::ChangeMask mask =
entities[i]->compute_change_mask(it->second);
if (mask != EntitySnapshot::CHANGED_NONE) {
stream.write_uint16(id);
stream.write_uint32(static_cast<uint32_t>(mask));
write_entity_delta(stream, *entities[i], it->second, mask);
}
}
}
}
// ---- Read Full ---------------------------------------------------------------
std::vector<EntitySnapshot> StateSerializer::read_full_snapshot(
Bitstream &stream,
uint32_t *out_tick
) {
std::vector<EntitySnapshot> result;
*out_tick = stream.read_uint32();
uint16_t count = stream.read_uint16();
uint16_t base_tick = stream.read_uint16(); // should be 0 for full
(void)base_tick; // unused in full read
result.reserve(count);
for (uint16_t i = 0; i < count; ++i) {
uint16_t id = stream.read_uint16();
EntitySnapshot::ChangeMask mask = read_change_mask(stream);
EntitySnapshot snap = read_entity(stream, mask);
snap.entity_id = id;
result.push_back(snap);
}
return result;
}
// ---- Read Delta --------------------------------------------------------------
void StateSerializer::read_delta_snapshot(
Bitstream &stream,
std::unordered_map<uint16_t, EntitySnapshot> &state
) {
uint32_t tick = stream.read_uint32();
uint16_t count = stream.read_uint16();
uint16_t base_tick = stream.read_uint16();
(void)tick;
(void)base_tick;
for (uint16_t i = 0; i < count; ++i) {
uint16_t id = stream.read_uint16();
EntitySnapshot::ChangeMask mask = read_change_mask(stream);
EntitySnapshot snap = read_entity(stream, mask);
snap.entity_id = id;
if (mask == EntitySnapshot::CHANGED_ALL) {
state[id] = snap; // replace entirely
} else {
// Merge delta into existing state
auto it = state.find(id);
if (it != state.end()) {
if (mask & EntitySnapshot::CHANGED_POSITION) it->second.position = snap.position;
if (mask & EntitySnapshot::CHANGED_VELOCITY) it->second.velocity = snap.velocity;
if (mask & EntitySnapshot::CHANGED_ROTATION) { it->second.yaw = snap.yaw; it->second.pitch = snap.pitch; }
if (mask & EntitySnapshot::CHANGED_HEALTH) it->second.health = snap.health;
if (mask & EntitySnapshot::CHANGED_ARMOR) it->second.armor = snap.armor;
if (mask & EntitySnapshot::CHANGED_WEAPON) it->second.weapon_id = snap.weapon_id;
if (mask & EntitySnapshot::CHANGED_AMMO) it->second.ammo = snap.ammo;
if (mask & EntitySnapshot::CHANGED_FLAGS) it->second.flags = snap.flags;
if (mask & EntitySnapshot::CHANGED_INPUT) it->second.last_processed_input = snap.last_processed_input;
} else {
state[id] = snap; // missing base, fall back to full replace
}
}
}
}
// ---- Private: write ----------------------------------------------------------
void StateSerializer::write_entity_full(Bitstream &stream, const Entity &entity) {
EntitySnapshot snap = entity.capture_snapshot();
write_entity_delta(stream, entity, EntitySnapshot{}, EntitySnapshot::CHANGED_ALL);
}
void StateSerializer::write_entity_delta(
Bitstream &stream,
const Entity &entity,
const EntitySnapshot &base,
EntitySnapshot::ChangeMask mask
) {
EntitySnapshot snap = entity.capture_snapshot();
// Position
if (mask & EntitySnapshot::CHANGED_POSITION) {
stream.write_float_quantized(snap.position.x, config_.pos_min, config_.pos_max, config_.pos_bits);
stream.write_float_quantized(snap.position.y, config_.pos_min, config_.pos_max, config_.pos_bits);
stream.write_float_quantized(snap.position.z, config_.pos_min, config_.pos_max, config_.pos_bits);
}
// Velocity
if (mask & EntitySnapshot::CHANGED_VELOCITY) {
stream.write_float_quantized(snap.velocity.x, config_.vel_min, config_.vel_max, config_.vel_bits);
stream.write_float_quantized(snap.velocity.y, config_.vel_min, config_.vel_max, config_.vel_bits);
stream.write_float_quantized(snap.velocity.z, config_.vel_min, config_.vel_max, config_.vel_bits);
}
// Rotation
if (mask & EntitySnapshot::CHANGED_ROTATION) {
stream.write_float_quantized(snap.yaw, config_.yaw_min, config_.yaw_max, config_.yaw_bits);
stream.write_float_quantized(snap.pitch, config_.pitch_min, config_.pitch_max, config_.pitch_bits);
}
// Health/Armor
if (mask & EntitySnapshot::CHANGED_HEALTH) {
stream.write_float_quantized(snap.health, config_.health_min, config_.health_max, config_.health_bits);
}
if (mask & EntitySnapshot::CHANGED_ARMOR) {
stream.write_float_quantized(snap.armor, config_.health_min, config_.health_max, config_.health_bits);
}
// Weapon/Ammo
if (mask & EntitySnapshot::CHANGED_WEAPON) {
stream.write_uint8(snap.weapon_id);
}
if (mask & EntitySnapshot::CHANGED_AMMO) {
stream.write_uint16(snap.ammo);
}
// Flags
if (mask & EntitySnapshot::CHANGED_FLAGS) {
stream.write_uint16(snap.flags);
}
// Input sequence
if (mask & EntitySnapshot::CHANGED_INPUT) {
stream.write_uint32(snap.last_processed_input);
}
}
// ---- Private: read -----------------------------------------------------------
EntitySnapshot::ChangeMask StateSerializer::read_change_mask(Bitstream &stream) {
return static_cast<EntitySnapshot::ChangeMask>(stream.read_uint32());
}
EntitySnapshot StateSerializer::read_entity(
Bitstream &stream,
EntitySnapshot::ChangeMask mask
) {
EntitySnapshot snap;
if (mask & EntitySnapshot::CHANGED_POSITION) {
snap.position.x = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits);
snap.position.y = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits);
snap.position.z = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits);
}
if (mask & EntitySnapshot::CHANGED_VELOCITY) {
snap.velocity.x = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits);
snap.velocity.y = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits);
snap.velocity.z = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits);
}
if (mask & EntitySnapshot::CHANGED_ROTATION) {
snap.yaw = stream.read_float_quantized(config_.yaw_min, config_.yaw_max, config_.yaw_bits);
snap.pitch = stream.read_float_quantized(config_.pitch_min, config_.pitch_max, config_.pitch_bits);
}
if (mask & EntitySnapshot::CHANGED_HEALTH) {
snap.health = stream.read_float_quantized(config_.health_min, config_.health_max, config_.health_bits);
}
if (mask & EntitySnapshot::CHANGED_ARMOR) {
snap.armor = stream.read_float_quantized(config_.health_min, config_.health_max, config_.health_bits);
}
if (mask & EntitySnapshot::CHANGED_WEAPON) {
snap.weapon_id = stream.read_uint8();
}
if (mask & EntitySnapshot::CHANGED_AMMO) {
snap.ammo = stream.read_uint16();
}
if (mask & EntitySnapshot::CHANGED_FLAGS) {
snap.flags = stream.read_uint16();
}
if (mask & EntitySnapshot::CHANGED_INPUT) {
snap.last_processed_input = stream.read_uint32();
}
return snap;
}
} // namespace tactical_shooter
@@ -0,0 +1,136 @@
#ifndef TACTICAL_SHOOTER_STATE_SERIALIZER_H
#define TACTICAL_SHOOTER_STATE_SERIALIZER_H
#include "bitstream.h"
#include "entity.h"
#include <cstdint>
#include <unordered_map>
#include <vector>
namespace tactical_shooter {
/**
* Serialization ranges for quantized float packing.
* These define the min/max and precision budget for each field type.
* Tune per-game to balance bandwidth vs accuracy.
*/
struct SerializationConfig {
// Position (quantized to 16 bits per axis = 65536 values)
float pos_min = -1024.0f;
float pos_max = 1024.0f;
uint8_t pos_bits = 16;
// Velocity (quantized to 12 bits per axis)
float vel_min = -32.0f;
float vel_max = 32.0f;
uint8_t vel_bits = 12;
// Rotation (yaw -180..180, pitch -90..90)
float yaw_min = -180.0f;
float yaw_max = 180.0f;
uint8_t yaw_bits = 12; // ~0.09° precision
float pitch_min = -90.0f;
float pitch_max = 90.0f;
uint8_t pitch_bits = 11; // ~0.09° precision
// Health/Armor (0..100, 7 bits = 0.79 precision)
float health_min = 0.0f;
float health_max = 100.0f;
uint8_t health_bits = 7;
// Ammo (0..255, 8 bits exact)
};
/**
* Delta-compressed snapshot serializer for network replication.
*
* Writes a compact binary representation of entity states suitable for
* 128Hz server-to-client replication. Full snapshots include all entities;
* delta snapshots only include entities with changes since the base snapshot.
*
* Snapshot on-wire layout:
* [uint16 tick] [uint16 count] [uint16 base_tick] <- header (6 bytes)
* for each entity:
* [uint16 entity_id] [uint32 change_mask]
* [changed fields per mask]
*
* Typical size at 128Hz, 10 entities:
* Full snapshot: ~300-400 bytes
* Delta snapshot (5 entities changed): ~150-250 bytes
*/
class StateSerializer {
public:
explicit StateSerializer(const SerializationConfig &config = SerializationConfig{});
/**
* Serialize a full snapshot including all entities.
*
* @param stream Output bitstream
* @param tick Current simulation tick number
* @param entities All active entities
* @param count Number of entities
*/
void write_full_snapshot(
Bitstream &stream,
uint32_t tick,
const Entity *const *entities,
uint16_t count
);
/**
* Serialize a delta snapshot — only entities that changed from base.
*
* @param stream Output bitstream
* @param tick Current simulation tick number
* @param entities All active entities
* @param count Number of entities
* @param base Base snapshot for delta comparison
*/
void write_delta_snapshot(
Bitstream &stream,
uint32_t tick,
const Entity *const *entities,
uint16_t count,
const std::unordered_map<uint16_t, EntitySnapshot> &base
);
/**
* Deserialize a full snapshot from a bitstream.
*/
std::vector<EntitySnapshot> read_full_snapshot(
Bitstream &stream,
uint32_t *out_tick
);
/**
* Deserialize a delta snapshot and apply to a base.
*/
void read_delta_snapshot(
Bitstream &stream,
std::unordered_map<uint16_t, EntitySnapshot> &state
);
/**
* Get the configuration.
*/
const SerializationConfig &config() const { return config_; }
private:
void write_entity_full(Bitstream &stream, const Entity &entity);
void write_entity_delta(
Bitstream &stream,
const Entity &entity,
const EntitySnapshot &base,
EntitySnapshot::ChangeMask mask
);
EntitySnapshot read_entity(Bitstream &stream, EntitySnapshot::ChangeMask mask);
EntitySnapshot::ChangeMask read_change_mask(Bitstream &stream);
SerializationConfig config_;
};
} // namespace tactical_shooter
#endif // TACTICAL_SHOOTER_STATE_SERIALIZER_H