4. Gameplay messages¶
Once the client's UDP endpoint is bound (section 3), the game runs over UDP:
the client sends its player's inputs, the server sends the state of the world in snapshots
and what happens in it as events. This section specifies the payload of the Input,
Snapshot and Event datagrams. The header is in section 2, the
acknowledgements and the resending of events in section 5.
The server is authoritative: it alone moves the entities, decides hits, deaths and scores. A client only sends what its player presses and shows what the server sends.
4.1 Common values¶
Entities¶
The server names each entity it sends by a network id, a u32 that stays the same for
the whole life of the entity. In the Aegis2D implementation it is Entity::id()
(ecs::NetworkId). The value 0xFFFFFFFF is never an entity: it means "none". The server
MAY reuse the id of a destroyed entity for a later one.
Each entity has a kind (u8):
| Kind | Name | Size (logical pixels) | Belongs to a player |
|---|---|---|---|
| 1 | PlayerShip |
33 × 17 | yes |
| 2 | PlayerMissile |
16 × 4 | yes, the shooter |
| 3 | BydosSlave |
32 × 32 | no |
| 4 | EnemyMissile |
8 × 8 | no |
Kinds 0 and 5 to 255 are reserved for later enemies and objects; snapshots have room
for kinds up to 7. A client MUST ignore an entity of a kind it does not know, and go on
reading the rest of the datagram (the records of section 4.3 and the events of section 4.4
have the same layout whatever the kind).
Coordinates and time¶
- Positions are the center of the entity in logical pixels, as
f32: the play area is 1280 × 720, the origin is its top-left corner,xgrows to the right andydownwards. Entities may be slightly outside the area (an enemy coming in, a missile leaving). - Velocities are in logical pixels per second, as
f32. Snapshots send both rounded, within the bounds of section 4.3. - The server simulates at 60 ticks per second and numbers its ticks with a
u32from0, the start of the game. At 60 ticks per second the counter does not wrap for over two years. - A position or a velocity that is not finite (NaN or infinity) is invalid: a receiver MUST ignore an event carrying one (section 6.4). Snapshots cannot carry one.
Players¶
Players are numbered 0 to 3, the player of LoginAccepted (section 3.2). The value
255 means "no player".
4.2 Input¶
Client → server, type 3. The client sends one Input per tick, 60 per second, while its
player is in the game.
| Field | Type | Content |
|---|---|---|
actions |
u8 |
Actions the player holds, one bit each |
| Bit | Value | Action |
|---|---|---|
| 0 | 0x01 |
Up (towards y = 0) |
| 1 | 0x02 |
Down |
| 2 | 0x04 |
Left |
| 3 | 0x08 |
Right |
| 4 | 0x10 |
Fire |
Bits 5 to 7 MUST be 0; the server MUST drop an Input with any of them set.
The client sends what is held, not what changed: a lost Input is replaced by the next
one 17 ms later, so inputs are never resent. The server keeps the actions of the most
recent Input of each player (by sequence, section 2.2), ignores older ones arriving
late, and applies them at each tick until a newer one arrives. The server alone turns them
into movement and shots (speed, fire rate, staying on screen), so a modified client cannot
move faster or fire more often.
Example: the player holds right and fire, so actions is 0x18.
4.3 Snapshot¶
Server → client, type 4. The server sends the state of the world to every client
20 times per second.
The payload is packed bit by bit, with the types of section 1.4 (Packed payloads):
| Field | Type | Content |
|---|---|---|
tick |
b32 |
Server tick of the state |
player_count |
b3 |
Number of player records, 0 to 4 |
then, player_count times: |
||
player |
b2 |
Player number |
has_ship |
bit |
Whether the player still has a ship |
ship |
varint |
Network id of the player's ship; absent without has_ship, the id is then 0xFFFFFFFF |
lives |
b8 |
Lives left |
score |
varint |
Score |
has_baseline |
bit |
Whether the entities are given against a baseline (4.8) |
baseline_age |
varint |
The tick of the snapshot minus the tick of its baseline, 1 or more; absent without has_baseline |
entity_count |
b16 |
Number of entity records |
then, entity_count times: |
||
index_gap |
varint |
The 24 low bits of the network id (the index), minus those of the previous record, or themselves for the first record |
generation |
b8 |
The 8 high bits of the network id |
change |
b2 |
0 updated, 1 added, 2 removed (4.8); 3 is invalid |
then, for an added entity (change = 1): |
||
kind |
b3 |
Kind (4.1) |
x, y |
s15, s15 |
Position, in eighths of a pixel |
vx, vy |
s12, s12 |
Velocity, in whole pixels per second |
frame |
b4 |
Frame of the entity's sprite sheet to show |
or, for an updated entity (change = 0): |
||
fields |
b4 |
The fields that follow, one bit each: bit 0 x, bit 1 y, bit 2 vx and vy, bit 3 frame; never 0 |
x, y, vx, vy, frame |
as above | Only those of fields, in this order |
(a removed entity, change = 2, has nothing after its change) |
Without a baseline, a snapshot is full: has_baseline is false and every record is an
added one, a record per entity. A client MUST drop a full Snapshot with an updated or a
removed record. Section 4.8 says how the server picks a baseline and how the client
rebuilds the world from it.
The server sends the entity records by increasing index, so index_gap is never negative;
a client MUST drop a Snapshot whose index goes past 24 bits. In Aegis2D the index is the
entity's slot and the generation its reuse counter (Entity::id()), so the indices of the
live entities stay close: an added record takes 79 bits, at most 10 bytes, while its gap is
below 128, then 87 bits up to 16383; an updated one 26 to 80 bits, a removed one 18. A
player record takes 27 to 59 bits.
Positions and velocities are rounded to the nearest value (halves away from zero) and clamped to their range. The errors are bounded:
| Field | Step | Range | Largest error within the range |
|---|---|---|---|
x, y |
1/8 pixel | -2048 to 2047.875 | 1/16 pixel |
vx, vy |
1 pixel per second | -2048 to 2047 | 0.5 pixel per second |
A drawn position, between two snapshots, mixes two received positions: it differs from
the server's by 1/16 pixel at most, well under half a pixel. A frame above 15 is sent as
15.
A datagram MUST NOT exceed 1200 bytes (section 1.3): the server puts as many entity records
in a Snapshot datagram as its payload holds, about 120. When the world has more, the
server sends several Snapshot datagrams with the same tick and the same baseline, each
with the player records and part of the records, its gaps starting from the index 0;
together they hold every record of the snapshot.
- A snapshot only updates entities, even through its added and removed records, which
only say what changed since its baseline. It never creates or destroys one in the
client's world: that is the job of the
SpawnandDestroyevents (4.4), which are reliable. A client MUST ignore a record whoseidit does not know (itsSpawnhas not arrived yet) or that it destroyed. - A client MUST ignore a record older than the last one it applied to the same entity (a
smaller
tick), as snapshots can arrive out of order. Records of the sametickin several datagrams are all applied. - Between two snapshots, the client interpolates the positions it received, or moves the entities with their velocity; it never decides where they really are.
4.4 Events¶
Events travel in Event datagrams (type 5), in both directions as section 2.3 allows,
but in version 1 only the server sends them. Section 5.2 frames them (count, then id,
length and bytes of each event) and section 5.3 resends them until they are acknowledged.
The bytes of each event are specified here: a u8 event type, the u32 server
tick at which it happened, then the fields of that type.
| Type | Name | Fields after type and tick |
Size |
|---|---|---|---|
| 1 | Spawn |
id (u32), kind (u8), x, y, vx, vy (f32), owner (u8) |
27 |
| 2 | Destroy |
id (u32), reason (u8), x, y (f32) |
18 |
| 3 | Death |
player (u8), lives (u8), x, y (f32) |
15 |
| 4 | PlayerJoined |
player (u8), name (string) |
8 + name |
| 5 | PlayerLeft |
player (u8), reason (u8) |
7 |
| 6 | GameOver |
count (u8), then count times player (u8) and score (u32) |
6 + 5 × count |
The size counts the type and the tick. Types 0 and 7 to 255 are reserved. A client
MUST ignore an event of a type it does not know, and an event whose bytes do not match its
type (too short, too long, or an invalid value): the datagram that carried it is valid and
acknowledged, and its other events are applied (section 6.4).
Spawn¶
An entity appears: the client creates it with its kind, position and velocity. owner is
the player it belongs to (the player of a ship, the shooter of a player missile), or 255.
A shot is the Spawn of a missile: the client plays the shot sound and effect then,
with owner telling which player fired. There is no separate fire event.
Destroy¶
An entity disappears, at position (x, y). The client removes it and remembers its id
(4.5).
reason |
Name | Meaning |
|---|---|---|
| 0 | Despawned |
It left the play area: no effect |
| 1 | Killed |
It was destroyed: the client shows an explosion |
| 2 | Consumed |
A missile that hit its target: it vanishes |
| 3 | PlayerLeft |
The ship of a player who left the game |
A client MUST treat an unknown reason as Killed.
Death¶
A player lost a life at (x, y): the client shows the explosion of the ship. lives is
the number of lives left. With lives left, the ship respawns at once: the same id keeps
moving in the snapshots, from its spawn point (the client moves it there at once, without
interpolating). With 0 lives, the player is out and a
Destroy of its ship follows.
PlayerJoined and PlayerLeft¶
PlayerJoined gives the number and the name (as in LoginRequest) of a player who joined
the game. PlayerLeft tells that a player left; reason is 0 (Left: logged out),
1 (TimedOut: no datagram for too long, section 5.5) or 2 (Kicked: protocol error,
section 3.2). A client MUST treat an unknown reason as Left.
GameOver¶
No player has lives left: the game is over. The event lists the final score of every
player of the game. The server ends the sessions afterwards (Logout, section 3.2).
4.5 Order and late arrivals¶
Events are reliable but MAY arrive in another order than they happened (section 5.3), and
snapshots are not reliable. A client keeps, for each network id, the tick of the last
Spawn or Destroy it applied, and:
- MUST ignore a
Spawnor aDestroywith a smallertickfor the same id. ADestroyarriving before itsSpawnis therefore remembered, and the lateSpawnignored; - MUST ignore snapshot records of an id whose last applied event is a
Destroywith atickgreater than or equal to the record's; - MAY forget a destroyed id 10 seconds after its
Destroy; the server SHOULD NOT reuse an id within those 10 seconds.
4.6 Joining a game¶
Right after the binding (Accept, section 3.4), the server sends the new client, as events:
a PlayerJoined for every player already in the game, then a Spawn for every entity
alive, so the client starts from the current world. Every other client receives a
PlayerJoined for the new player, and a Spawn of its ship when it appears.
When a player leaves, every remaining client receives PlayerLeft, then the Destroy of
its ship (reason PlayerLeft) if it still had one.
4.7 Example¶
A full snapshot at tick 600 with one player (player 0, ship 0x00000002, 3 lives,
score 100) and that ship alone, at (100, 360), still, frame 0:
58 02 00 00 A1 C0 00 99 00 00 01 80 02 32 00 5A 00 00 00 00
The 158 bits of this payload, in order:
| Bits | Field | Value | Bits written, first to last |
|---|---|---|---|
| 0 to 31 | tick |
600 | 00011010 01000000 00000000 00000000 |
| 32 to 34 | player_count |
1 | 100 |
| 35 to 36 | player |
0 | 00 |
| 37 | has_ship |
true | 1 |
| 38 to 45 | ship |
2: one group | 0100000 0 |
| 46 to 53 | lives |
3 | 11000000 |
| 54 to 61 | score |
100: one group | 0010011 0 |
| 62 | has_baseline |
false | 0 |
| 63 to 78 | entity_count |
1 | 10000000 00000000 |
| 79 to 86 | index_gap |
2 | 0100000 0 |
| 87 to 94 | generation |
0 | 00000000 |
| 95 to 96 | change |
1: added | 10 |
| 97 to 99 | kind |
1 | 100 |
| 100 to 114 | x |
800 eighths: 100 | 000001001100000 |
| 115 to 129 | y |
2880 eighths: 360 | 000000101101000 |
| 130 to 141 | vx |
0 | 000000000000 |
| 142 to 153 | vy |
0 | 000000000000 |
| 154 to 157 | frame |
0 | 0000 |
The 2 bits left in the last byte are 0. The same snapshot took 39 bytes with the first,
byte-aligned layout. Section 4.8 ends with the next snapshot, sent against this one.
The Spawn of a missile fired by that player at tick 612, id 0x00000009, at
(120, 360), flying right at 900 pixels per second:
01 64 02 00 00 09 00 00 00 02 00 00 F0 42 00 00 B4 43 00 00 61 44 00 00 00 00 00
typ tick 612 id knd x = 120 y = 360 vx = 900 vy = 0 owner
4.8 Baselines and delta snapshots¶
Between two snapshots, most entities keep their velocity and frame, and many do not move at all once rounded. So the server sends each client only what changed since a snapshot that client already has: its baseline.
Acknowledged snapshots¶
The server learns which snapshots a client has from the acknowledgements every datagram of
the client carries (section 5.1): a snapshot is
acknowledged once every datagram that carried it is. The server keeps, for each client,
the last snapshots it sent to it with the sequences of their datagrams (Aegis2D keeps 32,
1.6 seconds), and marks them acknowledged as the acknowledgements arrive. No field is added
for this: a client acknowledges a Snapshot datagram only once it rebuilt its snapshot
(below).
Choosing the baseline¶
For each snapshot and each client, the server:
- MUST NOT use as a baseline a snapshot that client has not acknowledged;
- SHOULD use the most recent one (the highest
tick) it acknowledged, the closest to the new state; - sends a full snapshot (4.3) when it has none, such as at the start of the game.
Each client thus gets the same state encoded against its own baseline. baseline_age is
the distance between the two ticks: at 20 snapshots per second it is 3 when the client
acknowledged the previous snapshot.
Records against a baseline¶
The server compares each entity of the snapshot, once rounded as 4.3 sends it, with the entity of the same network id in the baseline:
| In the baseline | In the snapshot | Record |
|---|---|---|
no, or of another kind |
yes | added (1), every field |
| yes, some fields differ | yes | updated (0), with the bits of fields that differ |
| yes, no field differs | yes | none |
| yes | no | removed (2) |
vx and vy go together: when one of them differs, both are sent. An index reused with
another generation is another network id: the old entity is removed, the new one added.
Rebuilding a snapshot¶
The client keeps the last snapshots it rebuilt, at least as many as the server keeps (32 in
Aegis2D), as the baselines of the next ones. For each Snapshot datagram, once its payload
is valid (section 6.3):
- Without
has_baseline, there is no baseline. Otherwise the baseline is the snapshot of ticktick − baseline_agethe client rebuilt. If it does not have it (never received, or forgotten), the client MUST drop the datagram and MUST NOT acknowledge it: the server then keeps using an older baseline, and never builds on a snapshot the client could not rebuild. - The entities are those of the baseline, in which each record is applied: an added one
sets every field of its network id, an updated one the fields of its
fields, a removed one deletes its network id. An updated or a removed record of a network id the baseline does not have makes the datagram unusable: the client drops it as in step 1. - The players are the player records of the datagram, whole.
- When a snapshot comes in several datagrams, they all share the
tickand the baseline: a datagram of the sametickagainst another baseline is dropped as in step 1. The snapshot is the baseline with the records of every datagram of the tick received so far: until the others arrive, the entities they hold keep their values of the baseline. A snapshot that misses a datagram is never acknowledged whole, so it is never a baseline. - The client keeps the snapshot rebuilt, replacing an earlier version of the same
tick, and applies its entities to its world as 4.3 says, at itstick.
A duplicate datagram is ignored (5.1); a late one is rebuilt and kept like the others, even when its records are older than the world's (4.3).
Example¶
The client acknowledged the full snapshot of 4.7, at tick 600. At tick 603, the ship
has moved to (105, 360) and flies right at 300 pixels per second, and a Bydos
(BydosSlave, id 0x00000005) has appeared at (1300, 200), flying left at 120 pixels
per second, frame 1. The server sends, with sequence 0x0025, acknowledging the
client's sequence 0x0040 and the 32 before it, in session 0xDEADBEEF:
52 54 59 50 01 04 25 00 40 00 FF FF FF FF EF BE AD DE
magic ver typ seq ack ack_bits session
5B 02 00 00 A1 C0 00 D9 01 01 00 01 00 0A 69 C0 12 00 30 00 D0 40 51 40 06 C4 07 80 00
payload, 29 bytes
The 227 bits of the payload, in order:
| Bits | Field | Value | Bits written, first to last |
|---|---|---|---|
| 0 to 31 | tick |
603 | 11011010 01000000 00000000 00000000 |
| 32 to 61 | the player record | as in 4.7 | |
| 62 | has_baseline |
true | 1 |
| 63 to 70 | baseline_age |
3: tick 600 | 1100000 0 |
| 71 to 86 | entity_count |
2 | 01000000 00000000 |
| 87 to 94 | index_gap |
2 | 0100000 0 |
| 95 to 102 | generation |
0 | 00000000 |
| 103 to 104 | change |
0: updated | 00 |
| 105 to 108 | fields |
x and velocity |
1010 |
| 109 to 123 | x |
840 eighths: 105 | 000100101100000 |
| 124 to 135 | vx |
300 | 001101001000 |
| 136 to 147 | vy |
0 | 000000000000 |
| 148 to 155 | index_gap |
3: index 5 | 1100000 0 |
| 156 to 163 | generation |
0 | 00000000 |
| 164 to 165 | change |
1: added | 10 |
| 166 to 168 | kind |
3 | 110 |
| 169 to 183 | x |
10400 eighths: 1300 | 000001010001010 |
| 184 to 198 | y |
1600 eighths: 200 | 000000100110000 |
| 199 to 210 | vx |
-120 | 000100011111 |
| 211 to 222 | vy |
0 | 000000000000 |
| 223 to 226 | frame |
1 | 1000 |
The 5 bits left in the last byte are 0. The client starts from its snapshot of tick
600, sets x, vx and vy of the ship, keeps its y (360) and frame (0), and adds
the Bydos. The ship's record took 61 bits instead of 79; had nothing changed since tick
600, the payload would have taken 87 bits (11 bytes), against 158 (20 bytes) for the full
snapshot.