Summary
If a system running a Changed(T) query is scheduled before the system that writes T in the same tick, the reader sees the writes on the first frame and never again. Ordered the other way round it works every frame.
Repro
import { World, Trait, Schedule, f32, Changed } from 'apecs';
const Position = new Trait({ x: f32(0) });
const run = (q) => { let n = 0; q.each(() => n++); return n; };
for (const readerFirst of [true, false]) {
const w = new World();
const e = w.spawn(Position);
const seen = [];
const reader = (w) => seen.push(run(w.query(Position, Changed(Position))));
const writer = (w) => w.set(e, Position.x, w.tick);
const s = readerFirst
? new Schedule().add('reader', reader).add('writer', writer, { after: 'reader' })
: new Schedule().add('writer', writer).add('reader', reader, { after: 'writer' });
for (let i = 0; i < 5; i++) s.run(w);
console.log(readerFirst ? 'reader first' : 'writer first', seen.join(','));
w.destroy();
}
// reader first 1,0,0,0,0
// writer first 1,1,1,1,1
Cause
RowFilter.begin in src/core/walk.ts sets horizon = lastSeen; lastSeen = ticks.tick, and accept requires row tick > horizon. With reader-first ordering at tick T:
- reader runs,
lastSeen = T
- writer stamps the row with
T
- next frame, tick T+1: reader runs with
horizon = T, row tick T > T is false, the write is dropped
- writer stamps
T+1, and the same thing repeats
Every write lands in the window the reader has already closed. Two runs of the same query inside one tick have the same problem: the second run closes the window on anything written between them.
Why it matters
The spec (§8.3) and README promise "only entities written since this query last ran". Nothing in the docs says readers must be ordered after their writers, and Schedule accepts either order without complaint. This is a silent lost-update, not an error.
Options
- Per-system ticks. Bevy advances the change tick after each system, so a write is always stamped later than the previous run of every other system.
Schedule.run could call world.step() before each system instead of once per frame. Cost: the tick counter advances N× faster, which matters for wraparound of the Uint32Array ticks.
- Half-open window. Accept
row tick >= horizon and set horizon = lastSeen + 1 only when the tick actually advanced. Same-tick re-runs would then see the same writes twice, which is arguably the correct answer.
- Document the hard rule that a
Changed reader must be ordered after every writer of that trait, and assert it in dev builds where the schedule can see both.
Found by a post-publish smoke test of 0.1.0.
Summary
If a system running a
Changed(T)query is scheduled before the system that writesTin the same tick, the reader sees the writes on the first frame and never again. Ordered the other way round it works every frame.Repro
Cause
RowFilter.begininsrc/core/walk.tssetshorizon = lastSeen; lastSeen = ticks.tick, andacceptrequiresrow tick > horizon. With reader-first ordering at tick T:lastSeen = TThorizon = T, row tickT > Tis false, the write is droppedT+1, and the same thing repeatsEvery write lands in the window the reader has already closed. Two runs of the same query inside one tick have the same problem: the second run closes the window on anything written between them.
Why it matters
The spec (§8.3) and README promise "only entities written since this query last ran". Nothing in the docs says readers must be ordered after their writers, and
Scheduleaccepts either order without complaint. This is a silent lost-update, not an error.Options
Schedule.runcould callworld.step()before each system instead of once per frame. Cost: the tick counter advances N× faster, which matters for wraparound of theUint32Arrayticks.row tick >= horizonand sethorizon = lastSeen + 1only when the tick actually advanced. Same-tick re-runs would then see the same writes twice, which is arguably the correct answer.Changedreader must be ordered after every writer of that trait, and assert it in dev builds where the schedule can see both.Found by a post-publish smoke test of 0.1.0.