Skip to content

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, x grows to the right and y downwards. 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 u32 from 0, 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 Spawn and Destroy events (4.4), which are reliable. A client MUST ignore a record whose id it does not know (its Spawn has 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 same tick in 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 Spawn or a Destroy with a smaller tick for the same id. A Destroy arriving before its Spawn is therefore remembered, and the late Spawn ignored;
  • MUST ignore snapshot records of an id whose last applied event is a Destroy with a tick greater 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):

  1. Without has_baseline, there is no baseline. Otherwise the baseline is the snapshot of tick tick − baseline_age the 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.
  2. 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.
  3. The players are the player records of the datagram, whole.
  4. When a snapshot comes in several datagrams, they all share the tick and the baseline: a datagram of the same tick against 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.
  5. 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 its tick.

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.