Skip to content
Lesson 6.8Windows Processes and Callable Interfaces

How an Engine Orders and Shares Its Work

See how an engine's systems share data through components, resources, and events, how one frame's work is ordered and run in parallel, and how the engine notices only what changed.

Lesson 6.8
Read or listenPrint chapter or book
About 32 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 6.8Key ideas, reference and questions

A one-page summary of “How an Engine Orders and Shares Its Work” you can keep beside your notes. Plain Markdown.

Lesson 6.8 — How an Engine Orders and Shares Its Work: cheatsheet

  • Remember: Systems share data in three ways: components belong to entities, a resource exists once for the world, and an event is a short-lived message.
  • Also useful: Think of a noticeboard where each notice comes down after two days. A student who is away on both days never reads it.

Key terms

  • resource: A resource is a value that exists once for the whole world, whatever entities there are: the clock, the score, the settings read at startup, the state of the keyboard.
  • query: That description is a query.
  • run condition: A run condition is a yes-or-no check the engine makes before a system runs, on every frame: is the game in the playing state, has a timer finished, does an enemy exist?
  • States: States are the usual source of conditions.
  • Hit: Hit is a marker component that the collision system adds to a brick, and GameState is an enum the game defines.
  • GameState: Hit is a marker component that the collision system adds to a brick, and GameState is an enum the game defines.
  • Res<Time>: Each piece matches a section above: Res<Time> is a resource, the two Query parameters are queries, Changed<Position> is change detection, Commands holds deferred commands, .chain() states the order, and .run_if(in_state(...)) is a run condition.
  • Changed<Position>: Each piece matches a section above: Res<Time> is a resource, the two Query parameters are queries, Changed<Position> is change detection, Commands holds deferred commands, .chain() states the order, and .run_if(in_state(...)) is a run condition.
  • Commands: Each piece matches a section above: Res<Time> is a resource, the two Query parameters are queries, Changed<Position> is change detection, Commands holds deferred commands, .chain() states the order, and .run_if(in_state(...)) is a run condition.

Review

  • Q: A tool writes 100 into a character's health on every frame, and the health is already 100. A health-bar system asks the engine for the entities whose health changed. What does that system receive? A: The character every frame, since a write stamps the component even when the value is equal
  • Q: A game has a paddle, a ball and 40 bricks that all have a position component, plus one score resource. What does that mean for a memory scan? A: The score is one record in one place, while positions form 42 values
Download cheatsheet (.md)

Lesson 4.1 split a game into entities, components, and systems, and followed one frame from input to the screen. A real frame runs dozens or hundreds of systems. Four questions decide what you will see when you look at such a game from outside:

  • How do systems pass data to one another?
  • Which system runs first, and which can run at the same time?
  • Which systems run on a given frame at all?
  • How does the engine avoid redoing work for things that did not change?

The answers explain symptoms that look like bugs: a value that updates one frame late, a copy that does not follow your edit, a number that freezes in a menu. They hold in every engine of this kind. Where a detail belongs to one engine, the text names it; the examples use the Breakout game from Lesson 4.1, with 1 paddle, 1 ball, and 40 bricks.

Systems never call one another. Each is a separate function the engine runs, and they cooperate only through data the engine owns. That data comes in three shapes:

Where shared data lives

Shape How many exist Who can read it In Breakout
Component one per entity that has it any system that asks for entities with it Position on the paddle, the ball, and 40 bricks: 42 values
Resource exactly one for each kind any system Score, the clock, the settings: one value each
Event a short queue of messages every system that reads that kind BrickHit for brick 7
writes
read by
writes
read by
sends
read by
read by
input system
resource: Intent
movement system
component: Position
(42 values)
collision system
event: BrickHit(brick 7)
score system
sound system

In memory the three look different. The 42 positions are one run of 42 records, which a scan finds together. A resource is one record at one address, so a scan for the score finds a single candidate instead of forty. An event is an entry in a queue that the engine empties again within a frame or two.

A resource is a value that exists once for the whole world, whatever entities there are: the clock, the score, the settings read at startup, the state of the keyboard. Because there is exactly one of each kind, a system asks for it by kind and needs no entity.

The choice between a resource and an entity is about how the data is used. If every system should treat the value like any other character or object, an entity is the better home. A single-player game’s player can be either. Keeping the player as an entity lets the same health-and-damage system serve the player and the enemies, and a resource would not. A score, a clock, or the current level belongs to the game as a whole, so a resource fits.

An event says what changed rather than what is true now, the distinction Lesson 4.9 draws for an outside observer. Inside an engine it carries one fact from the systems that notice it to the systems that care. The collision system does not know that a score or a sound exists; it sends BrickHit and moves on. Any number of systems can read it, and a new system, such as a particle burst, can be added later without touching the collision code.

Mechanically an event queue is a list. A sender appends to it. Each reader keeps its own count of how many entries it has read, so two readers each see every event once. The engine empties old entries after a short time. Bevy keeps each event for at least two frames, and other engines choose differently, so a system that does not run during that window never sees the event. A paused game is the usual case: the systems that read events stop, the events expire, and the reaction is lost for good.

Order matters as well, because a reader only sees what has already been sent when it runs:

ExampleOne brick hit, two orders

The game runs at 60 frames a second, so a frame is 1000 ÷ 60 = 16.67 ms and frame 10 starts at 10 × 16.67 = 166.7 ms. During frame 10 the collision system sends BrickHit. The score system reads it, but when depends on which runs first:

Order in the frame Score system reads the event in Score changes at Late by
collision, then score frame 10 166.7 ms none
score, then collision frame 11 183.3 ms one frame, 16.67 ms

One frame is short. A frame of delay between a bat swing and its sound or a key press and its effect is something a player can feel, which is why engines let you state the order.

One event, two system schedules. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

start frame 10 with score = 0
run the first selected system
collision publishes BrickHit(7)
score reads only published events
start frame 11 and read pending events
report the frame of the score change

Result: one score increment in frame 10. Order changed the latency, not the event’s meaning.

Final values: order = collision-first; frame = 10; pending = 0; score = 1; changed_frame = 10.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    order = collision-first.

  2. Step 2, line 1. The queue starts empty.

    order = collision-first; frame = 10; pending = 0; score = 0.

  3. Step 3, line 3. Collision publishes one brick-hit event.

    order = collision-first; frame = 10; pending = 1; score = 0.

  4. Step 4, line 4. Score reads the already published event during frame ten.

    order = collision-first; frame = 10; pending = 0; score = 1; changed_frame = 10.

  5. Step 5, line 6. Result: one score increment in frame 10. Order changed the latency, not the event’s meaning.

    order = collision-first; frame = 10; pending = 0; score = 1; changed_frame = 10.

Queries: asking for entities by what they have

Section titled “Queries: asking for entities by what they have”

A system does not loop over every entity and ask what it is. It describes the components it needs, and the engine hands it exactly the entities that have them. That description is a query. A query can say:

What a query can say

data wantedcomponents

Which components to read or write, such as Position and Velocity. An entity is included only if it has all of them.

with / withoutfilters

Components an entity must have, or must not have, without reading their values: every brick, or everything except the ball.

optionalcomponent

A component that may be missing. The system gets the value when it is there and None when it is not.

changed / addedfilters

Only entities whose component changed, or first appeared, since this system last ran. The next section covers these.

In Breakout, with 42 entities, the queries below match:

Query Entities it matches Count
Position and Velocity the ball 1
Position and Size the paddle, the ball, and the bricks 42
Position and Size, without Velocity the paddle and the bricks 41
Brick the bricks 40
PlayerControlled the paddle 1

A query that should match exactly one entity, such as the player, can ask for exactly one and fail loudly when there are two or none. A query can also hand out every pair of entities, and the cost of that grows quickly. With n entities there are n × (n − 1) ÷ 2 pairs:

pairs=n (n−1)2\text{pairs} = \frac{n\,(n-1)}{2}

For the 42 Breakout entities that is 42 × 41 ÷ 2 = 861 pairs. For 1,000 entities it is 1,000 × 999 ÷ 2 = 499,500 pairs, and at 60 steps a second that is 499,500 × 60 = 29,970,000 contact tests every second. This is why engines test only nearby pairs instead of all of them.

Change detection: do work only for what changed

Section titled “Change detection: do work only for what changed”

Some work only needs doing when a value changed. A health bar must be redrawn when health changes, and not on every frame for every character. An outside tool finds such changes by comparing two snapshots; an engine’s change detection records them as they are written, so a system can ask for exactly those entities.

The engine keeps a counter that rises with every frame. When a system writes a component, the engine stamps that value with the current counter. Each system remembers the counter from the last time it ran, and a Changed query returns the entities whose stamp is newer than that. Suppose the health-bar system last ran at counter 41:

Entity Health last written at Newer than 41? Redrawn?
A 40 no no
B 43 yes yes
C 44 yes yes

The saving can be large. With 10,000 characters and a redraw that costs 0.002 ms each, redrawing all of them costs 10,000 × 0.002 = 20 ms, more than a whole 16.67 ms frame. If only 12 changed this frame, the cost is 12 × 0.002 = 0.024 ms, about 833 times less.

Three consequences follow, and the third one matters when you edit memory:

  • A system that was paused and runs again still sees every change since it last ran, because it compares against its own counter. Events behave differently, as above.
  • A removed component is a special case: the data is gone, so there is no stamp to read. Engines keep a short list of the entities whose component was removed this frame instead.
  • Writing a value counts as a change even when the new value equals the old one. A tool that rewrites a value every frame therefore makes the engine believe it changes every frame, and everything triggered by that change runs every frame too.

Every system belongs to a schedule, a named phase of the frame, and the engine runs the phases in a fixed sequence. The names below are Bevy’s; other engines use other names for the same idea.

Startup: runs once, before the first frame
First and PreUpdate:
the clock and input
State transitions
Fixed steps: run 0, 1, or more times
Update: per-frame game logic
PostUpdate: the engine's own bookkeeping
Render and present

Two things about this list explain a great deal of what you will measure.

First, the fixed steps from Lesson 4.1 are a loop inside the frame. A fixed step is a slice of game time, not of real time, so the number of steps a frame runs depends on how long the frame took. With a 60 Hz step, a step is 1000 ÷ 60 = 16.67 ms. At 30 frames a second every frame runs 2 steps, at 60 exactly 1, and at 240 one frame in 4 runs a step.

Predict first

The game runs 60 fixed steps a second, so each step covers 16.67 ms of game time. The screen draws 144 frames a second, so each frame lasts 1000 ÷ 144 = 6.94 ms.

How many fixed steps does one frame run?

Answer: None on most frames, one on some: 5 of every 12. 12 frames last 12 × 6.94 = 83.33 ms, which holds 83.33 ÷ 16.67 = 5 whole steps. The leftover time carries over, so 5 of those 12 frames run one step and the other 7 run none.

Fixed steps inside drawn framesEach frame adds its length to the time the game owes; a fixed step runs each time a whole step’s worth has built up. Change the frame rate and follow 12 frames.

Step length, frame length, steps per frame

Two clocks

Step and frame length
1000 ÷ 60 = 16.67 msone fixed step
1000 ÷ 144 = 6.94 msone drawn frame

Fixed steps run in each of 12 frames

Steps in frames 1 to 6
0
0
1
0
1
0
Steps in frames 7 to 12
0
1
0
1
0
1
Over the 12 frames
5 steps12 × 6.94 = 83.33 ms of drawing

A step runs only when 16.67 ms have built up, so 7 frames run none and 5 frames run 1 step. The leftover time carries into the next frame.

The step rate is a setting of the game, so a value your tool watches can change once per step in one game and once per frame in another.

Second, the engine’s own work is made of ordinary systems that live in these same phases. Updating the world position of every object from its parents, and deciding which objects are visible, run in the bookkeeping phase near the end of the frame. Anything that reads those results earlier in the frame sees yesterday’s answer, which Lesson 7.1 shows for positions.

A system sees the version it was givenIn frame 7 the ball moves from x = 10 to x = 11. The engine copies positions into the world position only in PostUpdate, near the end of the frame.
  1. 1 Update: movement writes x = 11

    Position x = 11just written
    World position x = 10copied in frame 6

    The movement system writes the new position. The world position still holds the copy made in frame 6.

  2. 2 Update: a later system reads the world position

    Position x = 11
    World position x = 10read now: one frame old

    It gets 10, the value from one frame ago, although the position already says 11.

  3. 3 PostUpdate copies 11; rendering draws 11

    Position x = 11
    World position x = 11drawn at x = 11

    The bookkeeping phase updates the world position, so the systems after it and the renderer see 11.

In frame 7 the ball moves from x = 10 to x = 11. The engine copies positions into the world position only in PostUpdate, near the end of the frame.

Order and parallel work: when a system may run

Section titled “Order and parallel work: when a system may run”

Within a phase the engine decides which system runs next from what each one declares it reads and writes. A system is ready to run when all three conditions hold:

  • no system that is running right now writes any data this one touches, and none reads data this one writes;
  • every system that was ordered before it has finished or been skipped;
  • its run conditions, covered next, are true.

The first condition is what prevents the lost update of Lesson 6.6: two systems never write, or read and write, the same data at the same time. A ready system starts on any free thread of the engine’s worker pool, so systems that touch different data run at the same time. When the order of two systems is not stated, the engine may run them either way round, and the order can even change from one frame to the next.

Take five systems with these costs: read_input writes the player’s intent (1 ms), enemy_ai (3 ms), move_player writes Position and runs after read_input (1 ms), collide reads Position and sends events (2 ms), and play_sounds reads the events (1 ms). One thread would take 1 + 3 + 1 + 2 + 1 = 8 ms. On two threads the result depends on what enemy_ai touches:

The AI reads Position

enemy_ai reads Position, so move_player, which writes it, must wait for the AI to finish.

Time Thread A Thread B
0 to 1 ms read_input enemy_ai
1 to 3 ms idle: move_player cannot start enemy_ai
3 to 4 ms move_player
4 to 6 ms collide
6 to 7 ms play_sounds

The frame’s systems take 7 ms.

The AI reads its own copy

enemy_ai reads a copy of last frame’s positions, so nothing it touches conflicts.

Time Thread A Thread B
0 to 1 ms read_input enemy_ai
1 to 2 ms move_player enemy_ai
2 to 3 ms collide enemy_ai
3 to 4 ms collide
4 to 5 ms play_sounds

The longest chain, 1 + 1 + 2 + 1 = 5 ms, sets the frame’s systems at 5 ms. The price is that the AI sees positions one frame old.

An unstated order costs correctness as well as time. If move_player happens to run before read_input, a key pressed during frame N is not applied until frame N + 1. At 60 frames a second that adds 16.67 ms of delay, and at 30 frames a second it adds 33.33 ms. The same mistake in the other direction, a reader running before the writer it depends on, is the one-frame lag of the events example.

A run condition is a yes-or-no check the engine makes before a system runs, on every frame: is the game in the playing state, has a timer finished, does an enemy exist? When the answer is no, the system is skipped and anything it would have changed stays as it was. A condition can be attached to one system or to a whole set of them.

States are the usual source of conditions. A game is in exactly one state of a kind, such as menu, loading, playing, paused, or game over, and most gameplay systems run only in the playing state. Changing state is queued rather than immediate, and the engine carries it out at one fixed point in the next frame:

  1. A system asks for the new state. Nothing changes yet.
  2. At the start of the next frame the engine runs the exit work of the old state, such as despawning the match.
  3. It runs the work attached to the move itself.
  4. It runs the enter work of the new state, such as spawning the game-over screen.

This is why a value stops changing the moment you open a menu: its system has a condition that is now false, and nothing is wrong with the value. It also ties back to events. A system skipped for 120 frames reads nothing from an event queue that expired in two.

Creating or removing an entity while systems are running would move data that other systems are reading. So a system does not do it directly. It records a deferred command, such as “spawn a bullet here” or “despawn brick 7”, and the engine applies the queue at a safe point, normally the end of the phase, or between two systems whose order was stated.

The consequence is a delay you can measure. A bullet spawned by a system in frame N appears in the queries of later phases, but systems that already ran earlier in frame N cannot see it, and it first reaches them in frame N + 1, 16.67 ms later at 60 frames a second. A despawned entity is gone from the next query in the same way, and its memory is free to be reused, which is the reuse Lesson 3.10 warned about.

How components are stored: tables and sparse sets

Section titled “How components are stored: tables and sparse sets”

Lesson 4.1 said that each kind of component sits in its own run of memory. There are two common ways to arrange that, and an engine can offer both: table storage, where entities with the same set of components share columns, and sparse-set storage, where one kind of component lives in its own pool. Tables make reading fast and adding a component slow; sparse sets make adding cheap and reading slower.

Optional reference: table and sparse-set storage compared

Table storage

Entities with the same set of components share a table, and each component has a column of equal-sized records. Take Position (8 bytes), Velocity (8 bytes), and Health (4 bytes): one row is 8 + 8 + 4 = 20 bytes, and 10,000 entities make 200,000 bytes.

Position column10,000 × 8 bytes = 80,000 bytes, one run
Velocity column10,000 × 8 bytes = 80,000 bytes, one run
Health column10,000 × 4 bytes = 40,000 bytes, one run
One table: each component is one unbroken run, so a system that needs positions reads 80,000 bytes straight through.

Reading is fast, because a system walks one column from start to end. Adding a component to one entity is the slow case: its row no longer fits this table, so all 20 bytes move to a table that has the new column, and 4 more are written for a 4-byte Burning component.

Sparse-set storage

A component of this kind lives in its own pool, with an index from entity to position in the pool. Adding Burning to one entity appends one 4-byte record to that pool and an entry to the index. Nothing else moves.

Adding and removing is cheap, but reading costs an extra step for every entity, because the engine looks in the index before it reaches the value, and the values are not in entity order. That suits a marker that comes and goes every frame, such as “this enemy has noticed the player”, and does not suit data that every system reads.

Tables have a limit of their own. Each distinct set of components needs its own table. If 10,000 bullets all carry Position, Velocity, and Damage, they share one table. If each bullet may also carry any of 8 optional markers, up to 2⁸ = 256 different sets can appear, so the same 10,000 bullets are spread over as many as 256 tables, about 10,000 ÷ 256 = 39 to a table. Each table is a separate run in memory, and a scan for one component finds it in many pieces.

Everything above holds in any engine of this kind. If you would like to see the names, Bevy writes the same ideas in a few lines of Rust: a component is a plain struct, a system is an ordinary function, and a query is the function’s parameter list.

Optional: Breakout's systems in Bevy

A query for every entity that has both Position and Velocity, and the clock as a resource:

use bevy::prelude::*;
#[derive(Component)]
struct Position { x: f32, y: f32 }
#[derive(Component)]
struct Velocity { x: f32, y: f32 }
fn move_things(time: Res<Time>, mut query: Query<(&mut Position, &Velocity)>) {
for (mut position, velocity) in &mut query {
position.x += velocity.x * time.delta_secs();
position.y += velocity.y * time.delta_secs();
}
}
// Only the entities whose Position was written since this system last ran.
fn redraw_moved(query: Query<&Position, Changed<Position>>) {
for position in &query {
// update this entity's sprite from `position`
}
}

A command to remove the bricks that were hit, and the order and the run condition, which are written where the systems are added. Hit is a marker component that the collision system adds to a brick, and GameState is an enum the game defines. The other systems are written the same way:

fn remove_hit_bricks(mut commands: Commands, bricks: Query<Entity, With<Hit>>) {
for brick in &bricks {
commands.entity(brick).despawn(); // queued, applied at a safe point
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_state::<GameState>()
.add_systems(
Update,
(read_input, move_things, collide, remove_hit_bricks, play_sounds)
.chain() // run in this order
.run_if(in_state(GameState::Playing)), // skipped in every other state
)
.run();
}

Each piece matches a section above: Res<Time> is a resource, the two Query parameters are queries, Changed<Position> is change detection, Commands holds deferred commands, .chain() states the order, and .run_if(in_state(...)) is a run condition. These lines were compiled against Bevy 0.19. Bevy’s names change between versions, so check the one you use.

Three questions that come up once the pieces are in place, each worth a minute and none needed for the next lesson:

Could more CPU threads make the frame's systems finish faster than 5 ms?

No. The chain read_input, move_player, collide, play_sounds runs one system after another, because each needs the one before it: 1 + 1 + 2 + 1 = 5 ms. Extra threads can run enemy_ai beside the chain, but nothing can start a system before the one it waits for has finished. The longest chain of waiting systems sets a floor under the frame, whatever the number of threads. In the first tab above, enemy_ai joined that chain because it read Position, and the floor rose to 3 + 1 + 2 + 1 = 7 ms.

What happens when two systems write the same value and no order is stated?

The result depends on which one runs last. Say one system sets a character’s speed to 100 and another sets it to 50. If the first runs first, the speed ends the frame at 50, and if the second runs first, it ends at 100. An unstated order can change from one frame to the next, so the value can flip between 50 and 100 with no input from you. A number that alternates like that is a sign of two systems writing it.

Why are events dropped after a short time instead of kept until someone reads them?

A queue that was never emptied would grow for as long as the game ran. An event sent on every frame that nobody reads would add 60 × 60 = 3,600 events a minute at 60 frames a second. Dropping each event after a short, fixed time keeps the queue small, at the cost of the missed-event problem described above.

What this means when you look at a running game

Section titled “What this means when you look at a running game”

Each mechanism above leaves a mark you can see from outside. Use the table to turn a symptom into a question:

Symptoms and the engine mechanism behind them

What you see What to suspect
One value changes once per step, another once per frame the first is set by a fixed-step system, the second by a per-frame one
A copy of a value shows last frame’s number a system that reads it runs before the system that writes it
A spawned object is missing until the next frame the spawn is a command that has not been applied yet
A value freezes in a menu or on pause its system has a run condition that is now false
Rewriting a value every frame makes extra work happen a write counts as a change, so every change-triggered system runs
All the records of one kind sit in one run table storage, so a scan for it returns one tidy block
The same kind of record is scattered sparse-set storage, or many tables because of different component sets

Lesson 6.9 follows input from Windows into a frame like this one: polling, events, and window messages.

Knowledge check

Does an identical write count?

A tool writes 100 into a character's health on every frame, and the health is already 100. A health-bar system asks the engine for the entities whose health changed. What does that system receive?

Finished Lesson 6.8?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 6: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.