Chickens Must Die.Technical docs / Godot client

Godot 4 / Gameplay

Client Gameplay & Networking

A source-based guide to the playable client: input and networking, server-state presentation, movement, scene flows, camera, animation, chat and audio. Implemented behavior is kept separate from partial and inactive features.

Server-authoritative gameplayGodot / GDScriptColyseus

1. Current gameplay model

The client implements a small server-authoritative multiplayer arena presentation:

  • the player selects one of four chicken skins and a nickname;
  • every client joins the same Colyseus room type, my_room;
  • the backend publishes players and seeds;
  • the client sends directional intent rather than positions;
  • server positions drive the default local and remote presentation;
  • mass changes resize the player and its collision radius;
  • players can exchange chat messages;
  • a player_eaten event can play feedback for the local predator.

The client contains no local rules for seed pickup, consuming another player, spawning entities, victory, defeat, score, or mass calculation. Those outcomes can only appear through replicated state or server messages.

2. Controls and input behavior

Action Input
Move left A or Left Arrow
Move right D or Right Arrow
Move up W or Up Arrow
Move down S or Down Arrow
Send chat Enter while the message field is focused, or SEND
Release chat focus Escape, or left-click outside the focused message field
Settings OPTIONS in the menu or the global gear icon
Mute Global sound icon
Leave room Leave game in settings while connected

MovementInput returns zero when any LineEdit or TextEdit has focus, preventing keyboard movement while typing. There are no touch controls, virtual joystick, controller mappings, action/remapping UI, or accessibility alternatives in the current project settings.

Input.get_vector() is reduced to the sign of each axis. This produces eight possible non-zero directions and discards analog magnitude.

3. Join and room contract

Endpoint selection

NetConfig.COLYSEUS_URL is selected at script initialization:

const COLYSEUS_URL = (
    "http://172.22.111.106:8080"
    if Config.CLIENT_ENV == Config.ClientEnvEnum.DEVELOPMENT
    else "https://chickensmustdie.com"
)

The current CLIENT_ENV is DEVELOPMENT.

Join operation

The client calls:

client.join_or_create(
    "my_room",
    {
        "nickname": nickname,
        "playerColor": color,
    }
)

Supported playerColor values from the current UI are chicken_1, chicken_2, chicken_3, and chicken_4. The client trims the nickname and rejects only an empty result. The server must enforce all other validation.

Connection state machine

DISCONNECTED
  | join_game()
  v
CONNECTING
  | room.joined                         | creation/error/close during join
  v                                     v
CONNECTED --------------------------> DISCONNECTED
  | room.dropped        | leave_game()      ^
  v                     v                   |
RECONNECTING          LEAVING --------------+
  | room.reconnected            room.left or local disconnected check
  v
CONNECTED

The state machine also allows room.left from CONNECTED or RECONNECTING to finish the session and return to DISCONNECTED.

On a join error, the room/callback references are cleared before the join screen is re-enabled. The current source therefore supports another join attempt.

4. Expected room state

The client reads the state as a Godot Dictionary and expects two collections:

{
  "players": {
    "session-id-1": {
      "nickname": "Player One",
      "playerColor": "chicken_1",
      "x": 120.0,
      "y": 240.0,
      "mass": 5.0
    }
  },
  "seeds": {
    "seed-id-1": {
      "x": 360.0,
      "y": 180.0
    }
  }
}

Player fields

Field Client expectation Use
Collection key Session ID string Chooses local versus remote scene and provides stable identity.
nickname String/property accessible as player_state.nickname Label above the character.
playerColor String/property Animation prefix.
x, y Numeric/property Initial and authoritative target position.
mass Positive numeric/property Collision radius, sprite scale, label offset, and camera signal.

BasePlayer.setup() accesses fields through property syntax, while seed code uses dictionary get(). The backend schema/SDK representation must support the player access pattern.

The client does not validate finite positions, a recognized skin key, or positive mass. A malformed skin produces missing-animation warnings; invalid mass can reach sqrt().

Seed fields

Field Client expectation Use
Collection key Stable seed ID Instance lookup and removal.
x, y Numeric dictionary fields Visual position; absent values default to 0.0.

Seeds are presentation-only Node2D sprites. They have no collider or local pickup callback.

5. Replication and data flow

Player creation/removal

After room.joined, NetworkManager creates Colyseus.Callbacks.of(room). When gameplay loads, PlayerManager subscribes to players collection add/remove callbacks.

players entry added
  -> identify local session ID
  -> instantiate local_player.tscn or remote_player.tscn
  -> add child
  -> store by session ID
  -> BasePlayer.setup(state, session ID)
players entry removed
  -> queue_free player node
  -> erase dictionary entry

At every room.state_changed, Game also provides the full players dictionary to PlayerManager.apply_server_state(). The manager updates present entities, creates missing ones, and removes stale local ones. Callback and snapshot paths are intentionally redundant.

Seed reconciliation

Seeds use only a full dictionary diff from room.state_changed:

for each server seed:
  missing locally -> instantiate and setup
  present locally -> apply x/y
for each local seed:
  absent on server -> queue_free and erase

There is no client-side seed prediction. A seed disappears only when the server removes it from state.

Initial state handling

Game._ready() connects to future state_changed events and also defers one _on_state_changed() call. This covers a state snapshot that arrived between the room join and completion of the gameplay scene change.

6. Outgoing messages

move

NetworkInputSender emits input on a direction change and then at least once every 0.05 seconds while the component is active and the client remains CONNECTED.

{
  "seq": 42,
  "clientTime": 12345678,
  "input": {
    "left": false,
    "right": true,
    "up": false,
    "down": true
  }
}

Semantics:

  • seq starts at 1 and increases per local-player instance;
  • clientTime comes from Time.get_ticks_msec() and is not wall-clock/Unix time;
  • input describes intent, not a trusted position;
  • opposing directions cannot both be true from the current Vector2 representation;
  • diagonal input may contain one horizontal and one vertical flag;
  • sends are silently dropped by NetworkManager unless its state is CONNECTED.

The client does not use seq for acknowledgements or replay. If the backend includes an acknowledged sequence in its schema, the current client ignores it.

chat_send

{
  "text": "Hello!"
}

The chat panel trims leading/trailing whitespace, rejects empty text, and limits the UI field to 200 characters. It does not add an optimistic local message; display waits for a chat_message response/broadcast.

The backend remains responsible for rate limiting, authorization, sanitization, moderation, and enforcing its own length limit.

7. Incoming messages

Message type Client access pattern Current behavior Status
chat_message sentAt, nickname, text via get() Adds a message row and scrolls down. Implemented
chat_error payload.code Shows the code for three seconds. Implemented, assumes property exists
player_eaten payload.get("predatorSessionId", "") Matching local predator plays crunch sound. Implemented
game_config Opaque payload Emits a signal. Partial: no consumer
tick_sync Opaque payload Emits a signal. Partial: no consumer
__playground_message_types Opaque payload Debug log only. Development hook
Any other type Type only Warning log. Implemented fallback

A chat timestamp is expected to be Unix milliseconds. ChatMessage converts it to the system's local time-zone bias and displays [HH:MM].

8. Movement modes

Default behavior: server-position interpolation

RECONCILIATION_ENABLED is false. In this mode, the local player does not use MovementController to move itself. Both local and remote instances receive authoritative positions and interpolate toward them.

Differences:

Property Local Remote
Interpolation speed 18.0 12.0
Hard snap distance Default controller value, 100.0 px Default controller value, 100.0 px
Animation direction Current input direction Direction of interpolated visual movement
Collision during interpolation None None

Exponential interpolation uses:

var interpolation_weight := 1.0 - exp(-interpolation_speed * delta)
body.global_position = body.global_position.lerp(
    target_position,
    interpolation_weight
)

This is stable across varying frame durations. At 100 pixels of error or more, the controller snaps. At a tiny residual, it also snaps to avoid endless sub-pixel movement.

Because interpolation assigns global_position, local map collisions are not enforced in the default mode. The server must enforce collision and bounds and publish valid positions.

Optional free local simulation

When reconciliation is manually enabled, LocalPlayer runs MovementController. The configured movement mode is FREE:

  1. clamp each direction component to its sign;

  2. normalize diagonal direction;

  3. multiply by 100 * delta;

  4. move X with move_and_collide();

  5. move Y with move_and_collide().

Axis-separated collision movement permits sliding along obstacles. The backend must use compatible speed, diagonal normalization, collision geometry, and update cadence if this client path is enabled.

Optional grid simulation

Setting Config.MOVEMENT_TYPE to GRID selects a 16-pixel step system. It preserves a target until a step finishes and can consume enough frame movement to complete multiple steps. Before starting, test_move() checks the target; diagonal movement also checks horizontal and vertical cardinal paths.

This code is partial: it is disabled by current configuration and carries a source TODO to check the grid simulation. It should not be considered production-verified.

9. Prediction and reconciliation

The project calls the enabled local-simulation path reconciliation, but its exact capabilities matter.

What it does

  • moves the local player immediately from current input;
  • continuously stores the latest server position;
  • waits until the player has no input;
  • at most once per configured interval, compares local and server positions;
  • ignores errors up to 0.5 pixels;
  • smooths moderate error over 0.3 seconds through collision-aware axis moves;
  • hard-snaps error of 32 pixels or more.

What it does not do

  • no input history;
  • no server acknowledgement of seq;
  • no mapping between server tick and local simulation frame;
  • no rewind to authoritative state;
  • no replay of inputs after the acknowledged sequence;
  • no correction while input remains non-zero;
  • no server-provided movement constants, despite routed game_config/tick_sync messages.

The feature is therefore partial and disabled by default. It is basic client-side movement plus delayed idle correction, not full deterministic client prediction/reconciliation.

Correction lifecycle

new server position -> store only
each physics frame:
  moving?
    yes -> reset idle timer and cancel active correction
    no  -> increase idle timer
  active correction? -> apply a proportional part via move_and_collide
  interval reached AND idle >= 0.15 s?
    error <= 0.5       -> do nothing
    error >= 32        -> hard snap
    otherwise          -> begin 0.3 s correction

10. Collision, map, and camera

Map geometry

The runtime map is 800 x 608 pixels. Nine rectangles from the compiled map become StaticBody2D collision shapes. Four additional walls, each 64 pixels thick, are centered just outside the left, right, top, and bottom boundaries.

The local camera limits match 0,0 to 800,608. There is no dynamic link from loaded map dimensions to camera limits, so changing map size requires updating the local player scene as well.

Player mass and collision

The server's mass value affects presentation as follows:

Output Formula
Collision radius sqrt(mass) * 2.3
Sprite scale 0.5 * sqrt(mass / 5.0)
Name-label Y 7.5 * sqrt(mass / 5.0) - 4.1

mass_changed is emitted only when the new value differs approximately from current_mass.

Camera zoom

For the local player, camera zoom maps linearly from mass 5 at zoom 3 to mass 200 at zoom 2, with clamping outside the range. It then approaches the target exponentially at speed 2.

The mass signal is connected before base setup, so the initial server mass sets the camera target as well. The serialized camera still starts at zoom 3 and approaches that target using the existing smoothing.

11. Animation behavior

Each of the four skins defines these exact animation names:

chicken_1_walk  chicken_1_stand  chicken_1_idle
chicken_2_walk  chicken_2_stand  chicken_2_idle
chicken_3_walk  chicken_3_stand  chicken_3_idle
chicken_4_walk  chicken_4_stand  chicken_4_idle

Player animation rules:

  • non-zero direction -> walk;
  • zero for less than three seconds -> stand;
  • zero for at least three seconds -> idle;
  • moving right flips the sprite horizontally; moving left clears the flip;
  • vertical-only movement preserves the last horizontal orientation.

For the local player, animation is driven by input even in default server-interpolation mode. A blocked or rejected move may therefore still show walking. Remote animation uses actual interpolated movement direction.

The join preview cycles all three states on timers and validates animation existence before playing.

12. UI flow

Main menu
  START -> Join screen
  OPTIONS -> local SettingsMenu
  Logo click -> Splash -> fade -> Main menu
  EXIT -> quit
  LEADERBOARD -> no handler
  CREDITS -> no handler
Join screen
  Previous/Next -> change skin
  Join/Enter -> validate and connect
  Back -> Main menu
Gameplay
  Chat -> server round trip
  Global settings -> volumes / leave
  Global mute -> runtime Master mute

GlobalUi remains present across all scenes and has process mode Always. The main menu additionally owns another settings menu instance for its OPTIONS button. Both panels use the same SettingsManager, but they are separate scene instances.

13. Chat behavior

The chat panel is a CanvasLayer at layer 10 in the lower-left corner. It has no history cap: every received message creates another node for the lifetime of the gameplay scene.

Visibility behavior:

  • starts at opacity 0.3;
  • focus, send, message, or error changes it to 1.0;
  • after eight seconds of inactivity it fades to 0.3 over 0.4 seconds;
  • message receipt resets the timer;
  • errors remain visible for three seconds, independently of the opacity timer.

The client does not escape or transform message text before assigning it to a Godot Label. Godot labels do not interpret BBCode unless using a RichTextLabel, but the backend must still sanitize content appropriate to its storage and other clients.

14. Audio behavior

Music

Only one track, time_for_adventure.mp3, is registered as menu. It begins in the main menu and persists across scene changes because MusicManager is an autoload and no join/game code stops it.

Effects

  • UI button click: SFX bus, -15 dB, often started at 0.11 seconds.
  • Eating feedback: crunch.wav, default Master bus, randomized pitch.
  • Splash word/explosion clips: played by AudioStreamPlayer2D on default Master bus.

Settings and mute

Master, Music, and SFX sliders apply instantly and are saved when a settings panel closes. The global mute button uses SettingsManager, whose signal also updates the button after Master slider changes. Existing persistence and routing remain:

  • mute is not persisted;
  • the stored Master slider value does not change when mute toggles;
  • applying a positive Master slider value unmutes the bus;
  • the SFX slider affects the UI click but not the current eating/splash sounds.

15. Reconnection and failure behavior

The Colyseus room is configured to reconnect automatically up to eight times with bounded delay. Reconnect signals retain unsent chat input and refresh the world snapshot on recovery; there is no dedicated reconnect status overlay.

During RECONNECTING:

  • is_in_room is false;
  • all NetworkManager.send_message() calls return without sending;
  • the gameplay scene remains visible;
  • there is no pause or input lock;
  • default interpolation retains its last target;
  • if optional prediction is enabled, local simulation may continue visually while messages are discarded.

When the SDK restores the room, state returns to CONNECTED. When the room ultimately emits left, cleanup returns to the main menu. There is no dedicated terminal reconnect-failure message.

Intentional leave works while connected or reconnecting and has a 1.5-second check. If disconnected without left, local cleanup completes and detaches old room signals. If still connected, the manager retains the room, restores CONNECTED and reconnection options, logs a critical error, and permits another leave attempt. There is no force-close API in the wrapper.

16. Feature status matrix

Area Status Evidence/limit
Menu, join, optional splash Implemented Connected scene handlers and active main scene.
Four skins and three animations each Implemented SpriteFrames and runtime naming match.
Single-room Colyseus join Implemented Hard-coded my_room.
Join retry after error Implemented Join-time errors clean room/state.
Player/seed state replication Implemented Callback plus full-snapshot reconciliation.
Default position smoothing Implemented Local/remote interpolation active.
Client prediction/reconciliation Partial, disabled No ack/replay; idle correction only.
Free movement simulation Implemented but inactive by default Used only when reconciliation is enabled.
Grid movement Partial, inactive Source TODO requests verification.
Chat Implemented Server round trip, 200-char UI limit, error display.
Mass growth presentation Implemented Scale, circle radius, label, signal.
Camera response to mass changes Implemented Initial and later mass use the same signal connection.
Basic SDK reconnect Partial Config/signals exist; no UX or gameplay freeze.
Normal room leave Implemented room.left completes cleanup.
Stuck connected leave timeout Partial Retains the connected room and allows retry; no native force-close API.
Audio settings persistence Implemented for volumes Direct mute is not persisted.
Runtime map Implemented for one map No runtime selection; camera bounds are hard-coded.
Leaderboard / credits Not implemented Buttons have no handlers.
Server config / tick sync use Not implemented beyond routing Signals have no consumers.
Automated gameplay tests Not implemented No application test suite found.