Player state
The synchronized player state contains:
| Field | Meaning |
|---|---|
nickname |
Player display name. |
x, y |
Center of the server-side collider. |
left, right, up,
down
|
Directional input flags. |
mass |
Current player mass. |
lastProcessedSeq |
Sequence of the last processed input; used for client reconciliation. |
playerColor |
Selected character color variant. |
inputBuffer, gridTargetX, and
gridTargetY are server-only state.
Available color variants are chicken_1 through
chicken_4.
Joining a room
The room is named my_room and accepts up to
50 clients.
Authorization flow
- A Redis-backed rate limiter checks the IP address.
- Join options are validated.
- The nickname is normalized with NFKC and excess spaces are removed.
- The nickname must contain 3–20 Unicode characters.
-
Allowed characters are letters, combining marks, digits, spaces,
_, and-. - The name must contain at least one letter or digit.
- Uniqueness is checked within the room, case-insensitively.
On join, the server chooses a safe spawn point, creates the player,
and sends game_config and the initial
tick_sync.
Input buffer
Valid messages are placed in the player’s input buffer, which retains at most 60 of the newest entries. During each tick:
-
Input with
seq <= lastProcessedSeqis ignored. - Newer input updates the directional flags.
-
lastProcessedSeqsupports client-side reconciliation. -
clientTimeis validated but does not affect the authoritative simulation.
1.
Free movement
FreeMovementSystem handles smooth, continuous movement:
- It normalizes diagonal vectors so diagonal movement is not faster.
- It calculates travel distance from tick duration.
- It divides large moves into safe substeps to avoid passing through walls.
- It tests X and Y separately to allow sliding along obstacles.
- It uses the collider derived from the player’s current mass.
Screen-space coordinates increase downward:
up decreases y, while
down increases y.
Grid movement
GridMovementSystem moves players between cell centers.
The default grid is 16 px; the configuration also
accepts 32 px and 64 px.
- A target is created exactly one grid cell away from the current position.
- Reaching the target can take multiple ticks.
- Unused movement budget carries over to the next step.
- The path is checked against the current collider.
- Diagonal movement requires both the horizontal and vertical routes to be clear, preventing corner cutting.
- If a growing collider blocks the current path, the target is canceled.
In grid mode, the spawn point is at the center of a cell.
Configuration example
MOVEMENT_MODE=grid
GRID_SIZE=16
The map compiler and the server must use the same values. Docker Compose passes a shared configuration to both.
Collisions
The map supplies rectangular obstacles. Players may use either rectangular or circular colliders. Collision geometry handles:
- Player collider against a map rectangle.
- Player collider against a circular seed.
- Player-to-player collision.
- Map bounds.
- An epsilon to limit floating-point errors.
CollisionSpatialIndex divides the map into spatial
buckets, reducing the number of obstacles checked for each movement.
PlayerPositionGuardSystem remembers the last legal
position and restores it if an earlier system leaves the player
somewhere invalid.
Mass-based colliders
Collider scale is calculated as:
scale = (mass / initialMass) ^ colliderScaleExponent
With the default exponent of 0.5, collider size grows
proportionally to the square root of mass, producing behavior
similar to Agar.io. The same resolver is used by movement,
collisions, collectibles, and player eating.
Spawning
SpawnPositionFinder:
- Accounts for map bounds and collider size.
- Rejects positions that would cause a collision.
- Uses randomized placement in free movement mode.
- In grid mode, picks a starting point and then scans subsequent cells deterministically.
-
Accepts an optional
isAvailablepredicate, for example to avoid other players. -
Throws
SpawnPositionUnavailableErrorinstead of returning an unsafe position.
Seeds and mass growth
By default, 100 seeds are created when a room starts. Each seed:
- Has a radius of
2. - Spawns outside walls.
- Adds
3mass when collected. - Is removed immediately on collection, so two players cannot collect it during the same tick.
- Is immediately replaced with a new seed.
Collection is canceled if the resulting larger player collider would overlap a wall. This prevents growth through map geometry.
Player eating
One player can eat another only when all four conditions hold:
- The mass difference is at least
10. - The players’ colliders touch.
- The predator still fits at its current position after gaining mass.
- The server finds a safe respawn position for the victim.
Players are processed from highest mass to lowest. Session ID breaks ties independently of join order. A player cannot be eaten twice during the same tick.
After a successful eat
- The predator gains exactly the victim’s current mass.
gainedMassrepresents that transferred mass.- The victim returns to its initial mass.
- The victim’s directional flags, input buffer, and grid target are reset.
-
The victim’s
lastProcessedSeqadvances past pending inputs. -
A
player_eatenevent is sent to both predator and victim.
Chat
Chat text is normalized to NFC, control characters are removed, whitespace is collapsed, and leading/trailing whitespace is trimmed. After normalization, a message must contain 1–200 Unicode characters.
Valid messages are broadcast with a UUID, player ID, nickname, text,
and server timestamp. Invalid messages return
invalid_message; spam returns
rate_limited.
Time synchronization
The client receives the current tick on join. Afterward,
approximately once per second, the room broadcasts
serverTick and serverTime (Unix epoch
milliseconds).
Prepared but inactive features
-
ShootHandlerand the shoot-message schema exist as drafts but are not registered and are not part of the active protocol. -
The local
EventBusis not wired into the application. Integration events should use the RabbitMQEventPublisherport.