Skip to content

Bug?: SoA diff serialization: hasChanged never detects changes in nested array(f32) components due to epsilon comparison on arrays #213

Description

@jeanhadrien

Hello,

I'm making a multiplayer game that needs to send updates from server to client.
I'm using the suggested Observer/SoA serialization (with diff) pattern from the docs.
I have a PlayerSystem with a TrailPoints component like so :

const TrailPoints = {
  xs: array(f32),
  ys: array(f32),
};

the idea being that a single player entity has a list of [x,y] trail points

I've noticed TrailPoints were never actually considered as diffs when using epsilon.
I'd expect the lib to look into the arrays content and check for diffs there based on the epsilon value? maybe my expectations are wrong, idk.
Below is a reported generated with AI. I can confirm the base issue and workaround do work. I'm leaving the root cause and suggested fix in case it's useful somehow.

maybe a trailpoint should be an entity itself with a position component and it would be a child of the parent player entity? seems heavy but maybe the intended way?

using 0.4

AI Report

Summary

SoA diff serializer's hasChanged uses epsilon-based float comparison (Math.abs(NaN) > ε → false) on nested array(f32) components because isFloatType only looks at the outer array's element type marker, ignoring that the actual stored values are Arrays (not floats). Result: changes to nested array components are silently dropped from all diffs after the first serialization.

How to reproduce

import { array, f32, createSoASerializer } from 'bitecs/serialization';
// Nested array component: each entity stores its own f32 trail array
const TrailPoints = {
  xs: array(f32),  // outer SoA array, element type marker = $f32
  ys: array(f32),
};
// Add entity 0 with a 2-point trail
addEntity(world);
TrailPoints.xs[0] = [100, 200];
TrailPoints.ys[0] = [300, 400];
// Create diff serializer
const serialize = createSoASerializer([TrailPoints], { diff: true, epsilon: 0.0001 });
// First diff — works because shadow starts at 0, NaN !== Array
serialize([0]);  // includes trail data ✓
// Add a new point (new reference via spread)
TrailPoints.xs[0] = [...TrailPoints.xs[0], 500];
TrailPoints.ys[0] = [...TrailPoints.ys[0], 600];
// Second diff — never includes trail data ✗
serialize([0]);  // empty / 0 bytes

Expected behavior

The second call to the serializer should include the updated xs/ys arrays. Adding new elements changes the array reference, so the shadow comparison should detect the change.

Actual behavior

The second call returns 0 bytes (no data). TrailPoints changes are never detected after the first serialization.

Root cause

In hasChanged (and getEpsilonForType / isFloatType):

var isFloatType = (array2) => {
  const arrayType = getTypeForArray(array2);  // checks outer array's $arr marker
  return arrayType === $f32 || arrayType === $f64;
};
var hasChanged = (shadowMap, array2, index, epsilon) => {
  const shadow = getShadow(shadowMap, array2);
  const currentValue = array2[index];           // ← this is an Array, not a float!
  const actualEpsilon = getEpsilonForType(array2, epsilon);
  const changed = actualEpsilon > 0
    ? Math.abs(shadow[index] - currentValue) > actualEpsilon
    : shadow[index] !== currentValue;
  // ...
};

For nested array(f32) components:

  1. TrailPoints.xs is a branded array with $arr = f32
  2. getTypeForArray(TrailPoints.xs) returns $f32 — the outer array's element type
  3. isFloatType returns true
  4. getEpsilonForType returns ε = 0.0001
  5. actualEpsilon > 0 is true, so the float comparison path is taken:
    Math.abs(shadow[index] - currentValue) > epsilon
    
  6. Both shadow[index] and currentValue are Array objects (the per-entity trail array)
  7. Array - ArrayNaN
  8. Math.abs(NaN) > epsilonfalsealways false regardless of reference change

Workaround

Set epsilon: 0 when creating the SoA diff serializer. This forces the shadow[index] !== currentValue (reference equality) branch, which works correctly for nested arrays. The trade-off is that scalar float components lose epsilon tolerance for diff detection, but this is typically harmless for game ECS usage where entities are explicitly marked dirty.

Suggested fix

isFloatType (or getEpsilonForType) should not rely solely on the outer array's element type marker. For SoA array-of-array components, the elements at each index are themselves arrays, not scalar floats. One approach:

  • Check if array2[index] is array-like before using epsilon comparison, or
  • Add a depth concept to isFloatType that distinguishes "array of floats" from "array of float-arrays"

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions