A plain-English guide to every packet in the Attorney Online 2 protocol, as implemented by Whisker.
Who is this for? Client developers, bot authors, plugin writers, or anyone curious about what's actually going over the wire when you play AO.
Protocol docs credit: OmniTroid and the Attorney Online dev team — github.com/AttorneyOnline/docs
- Wire Format (FantaCode)
- TCP vs WebSocket
- Connection Lifecycle
- Handshake Packets
- In-Game Packets
- Server-to-Client Packets
- OOC Commands
- Pairing System
- Area Change Logic
- Rate Limiting
- Security
- Protocol Constants
Every AO2 packet looks like this on the wire:
HEADER#field1#field2#field3#%
- Header — A short string that identifies the packet type (
MS,CT,MC, etc.) - Fields — Separated by
# - Terminator — Every packet ends with
#%
Since # and % have special meaning, they need to be escaped when they appear inside field values:
| Character | Escape | Why |
|---|---|---|
# |
<num> |
Would be confused with field separator |
% |
<percent> |
Would be confused with packet terminator |
& |
<and> |
Used as sub-field separator in some packets |
$ |
<dollar> |
Reserved |
Example: If a player's message is "price is #100", it goes on the wire as "price is <num>100".
When the receiver parses the packet, it unescapes <num> back to #.
| What | Limit |
|---|---|
| Max packet size | 8,192 bytes |
| Max fields per packet | 1,024 |
Whisker supports both raw TCP (for the desktop AO2 client) and WebSocket (for webAO).
TCP: Packets arrive as a raw byte stream. The % character marks the end of each packet. Since % is always escaped in field values, a bare % is always a packet boundary.
WebSocket: Each WebSocket text frame contains one AO2 packet. The framing handles message boundaries, so the % terminator is technically redundant but still included.
| TCP | WebSocket | |
|---|---|---|
| Port | 27016 (default) | 27017 (default) |
| Message boundary | % character |
Frame boundary |
| Client masking | N/A | Required (RFC 6455) |
| Server masking | N/A | Not masked |
| Ping/pong | CH/CHECK packets | WS ping/pong frames + CH/CHECK |
| TLS | Not built-in | Via reverse proxy (Cloudflare/nginx) or direct WSS |
Here's what happens from the moment someone connects to when they're chatting in-game:
CLIENT SERVER
| |
| ---- TCP connect or WS handshake -----------> |
| <--- decryptor#0#% (legacy, no encryption) |
| |
| ---- HI#<hardware_id>#% -------------------> | Ban check happens here
| <--- ID#<players>#Whisker#0.1.0#% |
| <--- PN#<players>#<max>#<description>#% |
| |
| ---- ID#<client_name>#<version>#% ----------> |
| <--- FL#yellowtext#flipping#...#% | Feature list
| <--- ASS#<asset_url>#% | (if configured)
| |
| ---- askchaa#% -----------------------------> | Full check happens here
| <--- SI#<chars>#0#<music_count>#% |
| |
| ---- RC#% ---------------------------------> | Request characters
| <--- SC#<char1>&&&#<char2>&&&#...#% |
| |
| ---- RM#% ---------------------------------> | Request music
| <--- SM#<area1>#<area2>#...#<song1>#...#% |
| |
| ---- RD#% ---------------------------------> | "I'm ready"
| <--- DONE#% |
| <--- BN#<background>#% | Area state
| <--- HP#1#<defense_hp>#% |
| <--- HP#2#<prosecution_hp>#% |
| <--- LE#<evidence>#% |
| <--- CharsCheck#0#0#-1#0#...#% |
| <--- CT#Whisker#<motd>#1#% | MOTD (if set)
| |
| ---- CC#0#<char_id>#% ----------------------> | Pick character
| <--- PV#<uid>#CID#<char_id>#% |
| |
| (now in-game, can send MS/CT/MC/etc.) |
The handshake must complete within 60 seconds or the connection is dropped.
These packets happen in order during connection setup. You can't skip any.
decryptor#0#%
A legacy artifact from when the protocol supported FantaCrypt encryption. The value is a numeric FantaCrypt key. Whisker sends 0 because the AO2 client encrypts outgoing packets (HI, ID) with this key before receiving the FL noencryption flag. Key 0 makes the XOR encryption a no-op (byte ^ 0 = byte), so packets arrive as plain text without needing server-side decryption. Sent immediately when a client connects.
HI#<hardware_id>#%
The client sends its hardware ID (a unique device identifier). The server hashes it and checks the ban list.
| Field | Index | What it is |
|---|---|---|
| Hardware ID | 0 | Unique device string (hashed server-side for privacy) |
If banned: Server sends BD#<reason>#% and closes the connection.
Server -> Client (response to HI):
ID#<player_count>#Whisker#0.1.0#%
| Field | Index | What it is |
|---|---|---|
| Player count | 0 | How many people are online |
| Server software | 1 | Always "Whisker" |
| Server version | 2 | Version string |
Client -> Server (client identifies itself):
ID#<client_name>#<client_version>#%
| Field | Index | What it is |
|---|---|---|
| Client name | 0 | e.g., "AO2-Client", "webAO" |
| Client version | 1 | Version string |
PN#<current_players>#<max_players>#<description>#%
Sent right after the server's ID packet. Tells the client about server capacity.
| Field | Index | What it is |
|---|---|---|
| Current players | 0 | Players online right now |
| Max players | 1 | Server capacity |
| Description | 2 | Server description from config |
FL#yellowtext#flipping#customobjections#fastloading#noencryption#deskmod#evidence#cccc_ic_support#arup#casing_alerts#modcall_reason#looping_sfx#additive#effects#y_offset#expanded_desk_mods#auth_packet#%
Feature list. Each field is a feature the server supports. Clients use this to know what they can do. Whisker sends all 17 standard features.
ASS#<url>#%
Asset URL. Points to a CDN where clients can download character assets, themes, etc. Only sent if configured in config.toml.
askchaa#%
"Can I join?" — No fields. The server checks if there's room. If the server is full, it sends BD and closes.
SI#<character_count>#0#<music_and_area_count>#%
Server info. Tells the client how many characters and music tracks to expect.
| Field | Index | What it is |
|---|---|---|
| Character count | 0 | Total characters available |
| Evidence count | 1 | Always 0 during handshake |
| Music + area count | 2 | Total music tracks plus areas |
Client sends: RC#% ("give me the character list")
Server responds: SC#Phoenix&&&#Miles Edgeworth&&&#...#%
Each character name is followed by && (legacy placeholder for description and evidence, always empty).
Client sends: RM#% ("give me the music list")
Server responds: SM#Lobby#Courtroom#...#Trial.mp3#Pursuit.mp3#...#%
Areas come first (no file extension), then music tracks (with extensions like .mp3, .opus).
Client sends: RD#% ("I'm ready to join")
Server does:
- Assigns the player a unique UID (starts at 0, increments)
- Places them in the first area (area 0)
- Marks them as joined
- Sends
DONE#% - Sends area state (background, penalty bars, evidence, character availability)
- Sends MOTD if configured
- Broadcasts ARUP updates to everyone
After DONE, the player is in-game and can pick a character.
These only work after the handshake is complete.
Direction: Client -> Server
CC#0#<character_id>#%
| Field | Index | What it is |
|---|---|---|
| (unused) | 0 | Legacy field, ignored |
| Character ID | 1 | Index into the character list (0-based) |
| Password | 2 | Character password (optional, not validated) |
What happens:
- Server checks the character isn't already taken in this area
- Releases your previous character (if any)
- Claims the new one
- Sends back
PV#<your_uid>#CID#<char_id>#% - Broadcasts updated
CharsCheckto the area
You can't send IC messages until you've picked a character.
Direction: Client -> Server (then broadcast to area)
This is the big one — the most complex packet in the protocol. It carries everything about an IC message: the text, animations, effects, sound, and pairing info.
MS#0#preemote#Phoenix#normal#Hello world#wit#sfx.wav#0#48#0#0#0#0#0#Nick#-1#0&0#0#0#0#...#%
Client -> Server fields:
| Field | Index | What it is | Limits |
|---|---|---|---|
| Desk modifier | 0 | 0-5 (chat bubble style) | |
| Pre-animation | 1 | Animation played before talking | |
| Character | 2 | Character folder name | |
| Emote | 3 | Emote/animation name | |
| Message text | 4 | The actual dialogue | Max 256 chars |
| Side | 5 | Position (wit, def, pro, jud, hld, hlp) | |
| SFX | 6 | Sound effect filename | |
| Emote modifier | 7 | Emote modifier flags | |
| Character ID | 8 | Numeric character ID | |
| SFX delay | 9 | Sound effect delay (ms) | |
| Shout modifier | 10 | 0=none, 1=objection, 2=hold it, 3=take that, 4=custom | |
| Evidence | 11 | Evidence ID (0 = none) | |
| Flip | 12 | 0=normal, 1=mirror image | |
| Realization | 13 | 0=off, 1=on (screen flash) | |
| Text color | 14 | Text color code (0-8) | |
| Showname | 15 | Display name above text | Max 30 chars |
| Other char ID | 16 | Paired character (client sends -1) | |
| Self offset | 17 | Horizontal offset as "x&y" | |
| 18+ | noninterrupting_pre, sfx_looping, screenshake, frames_shake, frames_realization, frames_sfx, additive, effect, blips |
Server -> Client relay fields (server inserts 4 extra pairing fields):
| Field | Index | What it is |
|---|---|---|
| 0-15 | Same as client | |
| Other char ID | 16 | Partner's character ID (set by server, -1 if unpaired) |
| Other name | 17 | Partner's character folder name (inserted by server) |
| Other emote | 18 | Partner's last emote (inserted by server) |
| Self offset | 19 | Sender's offset (forwarded from client field 17) |
| Other offset | 20 | Partner's offset (inserted by server) |
| Other flip | 21 | Partner's flip state (inserted by server) |
| 22+ | Remaining client fields (18+), shifted by 4 |
Fields 16-18 and 20-21 are filled by the server. If you're paired with someone, the server inserts their character info so the client can render both characters side-by-side.
What happens when you send this:
- Mute check — if you're muted, rejected
- Character check — must have picked a character first
- Rate limit — max 20 IC messages per 10 seconds
- Message length — truncated to 256 chars if longer
- Pairing — server resolves your pair partner and inserts fields 17-18, 20-21
- Broadcast — sent to everyone in your area
Direction: Client -> Server (then broadcast to area)
CT#Phoenix_Wright#Hey everyone!#%
| Field | Index | What it is | Limits |
|---|---|---|---|
| OOC name | 0 | Your out-of-character name | 1-30 chars |
| Message | 1 | The message text | 1-256 chars |
Rate limit: 4 OOC messages per second.
Special behavior: If the message starts with /, it's treated as a command (see OOC Commands) and is NOT broadcast.
If you're OOC-muted, the packet is silently dropped.
Direction: Client -> Server (then broadcast to area)
MC#Trial.mp3#-1#Nick#1#0#0#%
This packet does double duty — it changes music OR moves you to a different area, depending on whether the name has a file extension.
| Field | Index | What it is |
|---|---|---|
| Name | 0 | Music filename OR area name |
| Character ID | 1 | Who's playing it (-1 for area change) |
| Showname | 2 | Display name (optional) |
| Looping | 3 | 0=once, 1=loop (server forces 1) |
| Channel | 4 | Audio channel (server forces 0) |
| Effects | 5 | Effect flags (server forces 0) |
How the server decides:
- Name contains
.(likeTrial.mp3) -> music change, broadcast to area - Name has no extension (like
Courtroom) -> area change, move the player
Direction: Client -> Server
CH#%
No fields. Just a heartbeat. Server responds with:
CHECK#%
The server expects a keepalive within 30 seconds. If it doesn't get one, the connection is dropped.
Direction: Client -> Server (then broadcast to area)
HP#1#7#%
| Field | Index | What it is |
|---|---|---|
| Bar ID | 0 | 1 = defense, 2 = prosecution |
| Value | 1 | Penalty points (0-10) |
Updates the penalty bar for the current area. Both bars start at 10 (full health) and decrease as penalties are given.
Three packets for managing evidence in the current area.
PE -- Add Evidence (Client -> Server):
PE#Murder Weapon#A bloody knife found at the scene#knife.png#%
| Field | Index | What it is | Limits |
|---|---|---|---|
| Name | 0 | Evidence display name | Max 255 chars |
| Description | 1 | Description text | Max 255 chars |
| Image | 2 | Image filename | Max 255 chars |
EE -- Edit Evidence (Client -> Server):
EE#0#Updated Name#New description#new_image.png#%
| Field | Index | What it is |
|---|---|---|
| Evidence ID | 0 | Which evidence to edit (0-based index) |
| Name | 1 | New name |
| Description | 2 | New description |
| Image | 3 | New image |
DE -- Delete Evidence (Client -> Server):
DE#0#%
| Field | Index | What it is |
|---|---|---|
| Evidence ID | 0 | Which evidence to delete (0-based index) |
After any evidence change, the server broadcasts the full evidence list using the LE packet (see Server-to-Client Packets).
Max 128 evidence items per area.
Direction: Client -> Server (then broadcast to area)
RT#testimony1#%
Triggers a judge/witness stand animation (like "Witness Testimony" or "Cross-Examination" splash screens). Server takes all fields as-is and broadcasts to the area.
Direction: Client -> Server (then to moderators only)
ZZ#Someone is spamming#%
| Field | Index | What it is |
|---|---|---|
| Reason | 0 | Why you need a mod (optional) |
What happens:
- Server logs:
[MODCALL] UID 5 (Phoenix) in area 'Courtroom': Someone is spamming - Every online moderator gets a notification (both a CT message and a ZZ packet for the audio cue)
- You get a confirmation message
This is NOT broadcast to regular players — only mods see it.
Direction: Client -> Server (then broadcast to ALL players)
CASEA#field1#field2#field3#field4#field5#field6#%
Broadcasts a case announcement to every connected player (not just your area). Used for the caseflow/announcement system.
Direction: Client -> Server
SETCASE#...#%
Acknowledged by the server but currently does nothing. Reserved for future use.
These packets are only sent by the server. Clients never send them.
BN#courtroom#%
| Field | Index | What it is |
|---|---|---|
| Background | 0 | Background image name for the current area |
Sent when you join an area or when a mod changes the background with /bg.
CharsCheck#0#0#-1#0#-1#0#%
One field per character in the character list:
0= available (free to pick)-1= taken (someone else is using it)
Sent when you join an area or when someone picks/releases a character.
ARUP#0#3#0#1#5#0#%
Bulk update about all areas at once. One data field per area.
| First field (type) | What each area field means |
|---|---|
0 (Players) |
Player count in that area |
1 (Status) |
Area status: IDLE, LOOKING-FOR-PLAYERS, CASING, RECESS, RP, GAMING |
2 (CM) |
CM if a case manager is present, FREE otherwise |
3 (Lock) |
FREE, SPECTATABLE, or LOCKED |
Broadcast to all joined players whenever someone joins, leaves, or area state changes.
LE#Murder Weapon&A bloody knife&knife.png#Autopsy Report&Time of death: 9PM&autopsy.png#%
Full evidence list for the area. Each evidence item has three sub-fields separated by &:
- Name
- Description
- Image
Sent when you enter an area or after any evidence change (PE/EE/DE).
These all mean "you're being disconnected" with different reasons:
BD -- Ban Disconnect (during handshake):
BD#You are banned from this server.#%
Sent if your IP or hardware ID is banned, or if the server is full.
KK -- Kicked:
KK#You have been kicked.#%
Sent by a moderator's /kick command.
KB -- Banned:
KB#Banned for 3 days: Spamming#%
Sent by a moderator's /ban command.
After any of these, the server closes your connection.
AUTH#1#%
| Value | Meaning |
|---|---|
1 |
Login successful -- you're a mod now |
0 |
Login failed -- wrong password |
-1 |
Logged out |
Sent in response to /login or /logout commands.
SP#def#%
| Field | Index | What it is |
|---|---|---|
| Position | 0 | Stage position: def, pro, wit, jud, hld, hlp |
Sent when you use /pos to change your IC position.
PV#5#CID#3#%
| Field | Index | What it is |
|---|---|---|
| UID | 0 | Your player ID |
| Literal | 1 | Always "CID" |
| Character ID | 2 | The character you picked |
Sent after you successfully pick a character with CC.
When an OOC message (CT packet) starts with /, it's treated as a command. The message is NOT broadcast — only you see the result.
| Command | What it does |
|---|---|
/help [command] |
Lists all commands, or shows help for a specific one |
/about |
Shows server name, version, and credits |
/area [name] |
Shows your current area, or moves you to a named area |
/areas |
Lists all areas with player counts and status |
/pos [position] |
Shows or sets your IC position (def, pro, wit, jud, hld, hlp) |
/ga or /players |
Lists everyone in your area with UIDs and character names |
/gas |
Lists all players in all areas (grouped by area) |
/charselect |
Go back to character select (releases your character) |
/global <message> |
Send an OOC message to everyone on the server |
/pm <uid> <message> |
Private message a player by their UID |
/pair <uid> |
Request to pair with someone for dual-character scenes |
/unpair |
Cancel your current pair |
/roll [NdM] |
Roll dice (default: 1d6). Example: /roll 2d20 rolls 2 twenty-sided dice |
You must /login first to use these.
| Command | What it does |
|---|---|
/login <user> <pass> |
Authenticate as a moderator |
/logout |
De-authenticate |
/kick <uid> |
Immediately disconnect a player |
/ban <uid> [reason] [duration] |
Ban a player (default: 3 days). Quote multi-word args: /ban 12 "ban evading" "3 days" |
/unban <index> |
Remove a ban by its index number |
/mute <uid> |
Prevent a player from sending IC messages |
/unmute <uid> |
Restore IC messaging |
/oocmute <uid> |
Prevent a player from sending OOC messages |
/oocunmute <uid> |
Restore OOC messaging |
/lock |
Lock the current area (requires CM or mod) |
/unlock |
Unlock the current area |
/invite <uid> |
Invite a player into a locked area |
/bg <background> |
Change the area's background image |
/forcepair <uid1> <uid2> |
Force two players into a pair |
/forceunpair <uid> |
Break a forced pair |
The duration is the optional third argument of /ban (after the UID and the
reason). It accepts these formats:
| Format | Duration |
|---|---|
30s |
30 seconds |
5m |
5 minutes |
1h |
1 hour |
3d |
3 days (default if omitted) |
1w |
1 week |
3 days, 1 week |
long forms work too (quote them) |
0 |
permanent |
Example: /ban 5 "Spamming the courtroom" 1h
Arguments are split into a quote-aware list before the command runs, so a multi-word reason or duration must be quoted to stay a single argument.
Pairing lets two players appear side-by-side during IC messages (like Phoenix and Maya). There are two types:
- Player A types
/pair 5(where 5 is Player B's UID) - Player B types
/pair 3(where 3 is Player A's UID) - Both have requested each other — pair is now active
- Their IC messages now include each other's character info in fields 16-21
Both players must be in the same area for the pair to be active. Either player can /unpair to cancel.
A mod types /forcepair 3 5 to force two players into a pair. This:
- Takes effect immediately (no mutual request needed)
- Persists across character changes and area moves
- Only breaks with
/forceunpairor disconnect
When Player A sends an MS packet and is paired with Player B, the server:
- Looks up Player B's character info
- Replaces field 16 with Player B's character ID
- Inserts field 17: Player B's character folder name
- Inserts field 18: Player B's last emote
- Forwards field 19: Player A's self_offset (from client field 17)
- Inserts field 20: Player B's last offset
- Inserts field 21: Player B's last flip state
- Remaining client fields shift by 4 positions
The client then renders both characters side-by-side using the pairing fields.
When you move to a new area (via /area or the MC packet), here's what happens step by step:
- Area exists? Server checks the area name is valid
- Lock check: If the area is
LOCKED:- Rejected unless you're on the invite list or you're a moderator
- Leave old area:
- Your character is released (marked free for others to pick)
- Player count decremented
- If the area is now empty, it resets (status -> IDLE, lock -> FREE, penalty bars -> 10)
- Enter new area:
- Player count incremented
- If your character is free in the new area, you keep it. Otherwise, you become a spectator
- State sync: Server sends you:
BN(background)HPx2 (both penalty bars)LE(evidence list)CharsCheck(character availability)
- Broadcast: ARUP updates sent to all players (player counts changed)
Whisker enforces rate limits to prevent spam and abuse. Each uses a sliding window.
| What | Limit | Window | What happens if exceeded |
|---|---|---|---|
| IC messages (MS) | 20 | 10 seconds | "You are sending messages too fast." |
| OOC messages (CT) | 4 | 1 second | "OOC rate limit. Slow down." |
| Raw packets (any) | 20 | 2 seconds | Disconnected immediately |
| New connections (per IP) | 10 | 10 seconds | Connection refused |
If someone keeps hammering connections past the limit, and conn_flood_autoban is enabled, they get auto-banned after 6 violations.
Whisker never stores raw IPs or hardware IDs. Both are hashed:
- IPID — Hashed from the client's IP address. Used for IP bans.
- HDID — Hashed from the hardware ID the client sends in the HI packet. Used for hardware bans.
Banning a player creates entries for both, so they can't just change their IP to get around it.
When behind Cloudflare or nginx (reverse_proxy_mode = true), Whisker reads the real client IP from these headers (in priority order):
X-Forwarded-For(first IP in the comma-separated list)X-Real-IPCF-Connecting-IP
This ensures bans work correctly even behind a proxy.
Bans have a duration and expire automatically. Expired bans are pruned from the ban list. The default ban duration is 3 days.
Max 16 connections from the same IP address. Prevents a single person from flooding the server with alt accounts.
For reference, here are all the hardcoded limits in the protocol:
Network
Default TCP port: 27016
Default WebSocket port: 27017
Default WSS port: 443
Max players: 100
Timeouts
Handshake timeout: 60,000 ms (60 seconds)
Keepalive interval: 10,000 ms (10 seconds)
Keepalive timeout: 30,000 ms (30 seconds)
WebSocket ping interval: 30,000 ms (30 seconds)
Packet Limits
Max packet size: 8,192 bytes
Max fields per packet: 1,024
Message Limits
Max IC message length: 256 characters
Max OOC message length: 256 characters
Max OOC name length: 30 characters
Max showname length: 30 characters
Evidence Limits
Max evidence name: 255 characters
Max evidence description: 255 characters
Max evidence image name: 255 characters
Max evidence per area: 128
Penalty Bars
Min value: 0
Max value: 10
Defense bar ID: 1
Prosecution bar ID: 2
Security
Max connections per IP: 16
Connection rate limit: 10 per 10 seconds
Flood ban threshold: 6 violations
FantaCode Escaping
# (hash) -> <num>
% (percent) -> <percent>
& (ampersand) -> <and>
$ (dollar) -> <dollar>
For client/bot developers who need to implement the WebSocket layer directly.
Standard HTTP upgrade:
GET / HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server responds with 101 Switching Protocols and the accept key (SHA-1 of your key + the magic GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, base64 encoded).
Byte 0: [FIN:1][RSV:3][Opcode:4]
Byte 1: [MASK:1][Length:7]
Bytes 2+: Extended length (if needed) + Mask key (if masked) + Payload
| Opcode | Meaning |
|---|---|
0x1 |
Text frame (AO2 packets go here) |
0x8 |
Close |
0x9 |
Ping (server auto-responds with pong) |
0xA |
Pong |
Max frame size: 65,536 bytes.
Client-to-server frames must be masked. Server-to-client frames are never masked.
Protocol documentation based on the work of OmniTroid and the Attorney Online dev team. Adapted and expanded for Whisker.