Build an Autonomous Among Us AI Agent

Build a Python bot that joins Among Us, tracks game state, and uses a local LLM to chat and vote.

Introduction

30 Second Summary

Social deduction games like Among Us are built on reading people. What if you could build an AI player that bluffs, accuses, and votes just like a human?

In this project, you will build an autonomous AI agent that joins a self-hosted Among Us server as a real player. The bot speaks the game's network protocol, tracks what's happening in real-time, and uses a local LLM to chat and vote during meetings.

What You'll Build

A Python-based AI agent that connects to your own Among Us private server using the Hazel UDP protocol, monitors game state, and generates social deduction chat powered by a local language model.

By the end of this project, you'll have:

  • A self-hosted Among Us private server running on your Linux machine that friends can connect to with their real game clients.
  • A Python async protocol client that speaks the Among Us Hazel UDP protocol from scratch, handling connections, game state, and player tracking.
  • An AI meeting brain powered by Ollama that generates convincing chat messages and casts strategic votes during discussions.
  • Secret Mission: Give the bot an Impostor brain that lies, accuses others, and creates alibis when it gets assigned the evil role.

Are there any prerequisites?

You should have basic Python knowledge and be comfortable using a Linux terminal. You also need a Linux machine (Ubuntu-based) with an NVIDIA GPU.

Your friends will need to own Among Us on any platform (PC, mobile, or console) to connect and play against your bot.

Set Up the Impostor Server

Your AI agent needs a private Among Us server to connect to. The official Innersloth servers require account authentication and have anti-cheat measures that would block a bot.

In this step, you will self-host an Impostor server on your Linux machine. This gives you full control over the game environment and lets your bot connect without any restrictions.

In this step, get ready to:
  • Install the .NET 8 ASP.NET Core Runtime.
  • Download and configure the Impostor server.
  • Start the server and verify it is listening.
Install the .NET 8 ASP.NET Core Runtime

The Impostor server is a .NET application. It needs the ASP.NET Core Runtime 8 installed on your system before it can run.

  • Check if you already have the runtime installed by running this command:
dotnet --list-runtimes

✔️ I see Microsoft.AspNetCore.App 8.x listed

You already have the ASP.NET Core Runtime 8 installed. Skip ahead to the next substep.

ⓧ I see an older version or nothing ASP.NET-related

  • Install the ASP.NET Core Runtime 8 by running these commands:
sudo apt-get update && \
  sudo apt-get install -y aspnetcore-runtime-8.0

What does this command do?

This uses your system's package manager to download and install the ASP.NET Core Runtime version 8.0. The -y flag automatically confirms the installation prompt.

Package not found?

If apt cannot find aspnetcore-runtime-8.0, you may need to add the .NET backports repository first. Run the following, then retry the install command above:

sudo add-apt-repository ppa:dotnet/backports
sudo apt-get update

If you are still stuck, help me install .NET 8 ASP.NET Core Runtime on my Ubuntu-based Linux.

ⓧ Command not found

The dotnet command is not installed at all. That is expected.

  • Install the ASP.NET Core Runtime 8 by running these commands:
sudo apt-get update && \
  sudo apt-get install -y aspnetcore-runtime-8.0

Package not found?

If apt cannot find aspnetcore-runtime-8.0, you may need to add the .NET backports repository first. Run the following, then retry the install command above:

sudo add-apt-repository ppa:dotnet/backports
sudo apt-get update

If you are still stuck, help me install .NET 8 ASP.NET Core Runtime on my Ubuntu-based Linux.

  • Verify the runtime is installed by running:
dotnet --list-runtimes

You should see a line containing Microsoft.AspNetCore.App 8 in the output.

Download and configure the Impostor server

Impostor is an open-source private server for Among Us. Version 1.10.4 supports Among Us versions 2024.6.18 up to and including 17.0.0.

  • Create a directory for the server and move into it by running these commands:
mkdir -p ~/impostor
cd ~/impostor
  • Download the Impostor server binary by running:
wget https://github.com/Impostor/Impostor/releases/download/v1.10.4/Impostor-Server_1.10.4_linux-x64.tar.gz
  • Extract the archive by running:
tar -xzf Impostor-Server_1.10.4_linux-x64.tar.gz
  • Make the server binary executable by running:
chmod +x Impostor.Server

Why chmod +x?

Files downloaded from the internet often lose their executable permission. The chmod +x command grants the binary permission to run as a program.

Now you need to configure the server so it knows which IP address to listen on.

  • Open the config.json file in a text editor by running:
nano ~/impostor/config.json
  • Replace the entire contents of the file with the following configuration:
{
  "Server": {
    "PublicIp": "127.0.0.1",
    "PublicPort": 22023,
    "ListenIp": "0.0.0.0",
    "ListenPort": 22023
  }
}

What do these settings mean?

  • PublicIp is the address clients use to reach your server. For local testing, 127.0.0.1 works. If friends are connecting over the internet, change this to your public IP.
  • ListenIp set to 0.0.0.0 means the server accepts connections on all network interfaces.
  • Port 22023 is the default Among Us game port, used for both TCP (HTTP matchmaking) and UDP (game traffic).
  • Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
  • Confirm the files are in place by running:
ls ~/impostor

You should see Impostor.Server, config.json, and the downloaded tarball in the output.

Start the server and verify it is listening

With the runtime installed and the config in place, you can now launch the Impostor server.

  • Start the Impostor server by running:
cd ~/impostor
./Impostor.Server

You should see log output indicating the server has started and is listening on port 22023.

What should I see?

The server prints startup messages to the terminal. Look for lines mentioning that it is listening on the configured port. The server keeps running in the foreground until you stop it with Ctrl+C.

  • Open a second terminal tab or window to verify the port is active. Run:
ss -ulnp | grep 22023

You should see a line showing UDP port 22023 in use. This confirms Impostor is listening for game connections.

Not seeing port 22023?

  • Make sure you ran chmod +x Impostor.Server before launching. Without execute permissions, the binary will not start.
  • Check that config.json is in the same directory as the Impostor.Server binary. The server looks for the config file next to the executable.
  • If you see a .NET-related error, verify the runtime is installed with dotnet --list-runtimes.

If you are still stuck, help me troubleshoot why Impostor.Server is not starting on my Linux machine.

Keep the server running

Leave the first terminal tab running the server for the rest of this project. You will use the second terminal tab for all remaining commands.

Your private Among Us server is live. Next up, you will install Ollama and set up the AI model that powers your bot's brain.

Set Up Ollama and Python Environment

Your Among Us server is running and ready for connections. Now you need the AI brain that will power your bot's social deduction during meetings.

In this step, you'll install Ollama to run a local language model, then set up a Python environment for the bot's code. The model will generate chat messages and vote decisions during meetings.

In this step, get ready to:
  • Install Ollama and pull the gemma2:2b model.
  • Create a Python virtual environment with the requests library.
  • Verify Ollama responds to API calls and generates text.
Install Ollama and pull the gemma2:2b model

Ollama lets you run large language models locally on your machine. Your bot will call the Ollama API during meetings to generate human-sounding chat messages and decide who to vote for.

  • Install Ollama by running this command:
curl -fsSL https://ollama.com/install.sh | sh

What does this command do?

This downloads the official Ollama install script and runs it. The script detects your OS and GPU, then installs Ollama as a system service.

After installation, the Ollama API is available at 127.0.0.1:11434.

  • You should see output ending with a message confirming Ollama is installed and the API is available.
  • The install script will detect your NVIDIA RTX 2050 GPU and configure GPU acceleration automatically.

Seeing a permission error?

The install script needs elevated privileges. If it fails, try running it with sudo or check that your user has admin rights.

I'm getting a permission error when installing Ollama on Linux. How do I fix this?

Now you need to download the language model. Your RTX 2050 has 4GB of VRAM, so you'll use gemma2:2b which fits comfortably at around 1.5GB.

Why gemma2:2b instead of a larger model?

Larger models like gemma3:4b require over 6GB of VRAM. Your 4GB GPU would run out of memory trying to load them.

The 2B parameter model is small enough to fit in VRAM while still generating convincing short chat messages for Among Us meetings.

  • Pull the gemma2:2b model by running:
ollama pull gemma2:2b
  • Wait for the download to complete. This will download roughly 1.6GB of model data.
  • Verify the model is available by running:
ollama ls
  • You should see gemma2:2b listed in the output.

Download stuck or timing out?

Check your internet connection. If the download fails partway through, run the ollama pull gemma2:2b command again. Ollama resumes partial downloads automatically.

The ollama pull command for gemma2:2b is stuck or timing out. How can I troubleshoot this?

Set up the Python virtual environment

Your bot's code is written in Python. A virtual environment keeps the project's dependencies isolated from the rest of your system.

  • Create the project directory and move into it by running these commands:
mkdir ~/among-us-bot
cd ~/among-us-bot
  • Create and activate a Python virtual environment by running:
python3 -m venv venv
source venv/bin/activate

What does this do?

The python3 -m venv venv command creates an isolated Python environment inside a venv/ folder. The source venv/bin/activate command activates it so any packages you install go into this environment only.

You'll see (venv) appear at the start of your terminal prompt, confirming the virtual environment is active.

  • Install the requests library by running:
pip install requests
  • You should see output confirming requests was installed successfully.

Seeing 'python3: command not found'?

Make sure Python 3 is installed on your system. On Ubuntu-based Linux, install it by running:

sudo apt install python3 python3-venv

Then re-run the virtual environment commands above.

I'm getting a python3 or venv module not found error on my Linux system. How do I fix this?

Verify Ollama responds to API calls

Before writing any bot code, confirm that Ollama is serving the model and can generate text through its API. Your bot will use this exact endpoint during games.

  • Test the Ollama API by running this command:
curl http://localhost:11434/api/generate -d '{
  "model": "gemma2:2b",
  "prompt": "Say hello in one sentence.",
  "stream": false
}'

What does this command do?

This sends a POST request to the Ollama /api/generate endpoint. It asks the gemma2:2b model to generate a short response.

The "stream": false flag tells Ollama to return the full response in one JSON object instead of streaming tokens.

  • You should see a JSON response containing a response field with generated text. The first request may take a few seconds while the model loads into GPU memory.

✔️ I see a JSON response with generated text

Ollama is working correctly. Your bot will use this same API endpoint to generate meeting chat and vote decisions during games.

ⓧ I see a 'connection refused' error

The Ollama service is not running. Start it manually by running:

ollama serve

Open a new terminal tab and re-run the curl command. If Ollama was installed as a systemd service, you can also try:

sudo systemctl start ollama

I'm getting a connection refused error when trying to reach the Ollama API. How do I start the server?

ⓧ I see a model not found error

The model did not download successfully. Pull it again by running:

ollama pull gemma2:2b

After the download completes, re-run the curl test command.

Ollama says the gemma2:2b model is not found even though I ran ollama pull. How do I fix this?

Your AI infrastructure is ready. Ollama is serving the gemma2:2b model locally, and your Python environment is set up with the libraries you need. Next, you'll build the network protocol client that connects your bot to the Among Us server.

Build the Hazel Protocol Client

Your Impostor server is listening for game connections on port 22023. To connect a bot as a real player, you need to speak the exact same network protocol the game uses: Hazel UDP.

In this step, you'll build a Python client that handles the Hazel protocol from scratch. This includes the initial Hello handshake, reliable message delivery with acknowledgements, and keepalive pings that stop the server from dropping you.

In this step, get ready to:
  • Implement the UDP socket connection with async Python.
  • Build packet parsing for the Hazel protocol (Hello, Reliable, Ack, Ping).
  • Connect to the Impostor server and complete the handshake.
Implement the UDP socket connection with async Python

Among Us uses the Hazel Networking library for all communication between clients and servers. Every packet starts with a single byte that identifies the packet type.

What is the Hazel protocol?

Hazel is a lightweight UDP networking library used by Among Us on all platforms. It adds reliability features on top of raw UDP, like packet acknowledgement and ordered delivery.

The protocol defines six packet types: Normal (fire-and-forget), Reliable (must be acknowledged), Hello (handshake), Disconnect, Ack (acknowledgement), and Ping (keepalive). Your client needs to handle all six.

  • Create the hazel_client.py file in your ~/among-us-bot/ folder by running:
touch hazel_client.py
  • Confirm the file was created by running:
ls

You should see hazel_client.py listed in the output.

  • Open hazel_client.py in your text editor and add the imports, protocol constants, and class initialization:
import asyncio
import struct
import socket
import time

# Hazel Protocol Constants
SEND_OPTION_NORMAL = 0x00
SEND_OPTION_RELIABLE = 0x01
SEND_OPTION_HELLO = 0x08
SEND_OPTION_DISCONNECT = 0x09
SEND_OPTION_ACK = 0x0A
SEND_OPTION_PING = 0x0C

# Among Us Game Version (17.0.0 encoded)
GAME_VERSION = 50537550

class HazelClient:
    def __init__(self, server_ip: str, server_port: int = 22023):
        self.server_ip = server_ip
        self.server_port = server_port
        self.sock = None
        self.connected = False
        self.nonce = 0
        self.pending_acks = {}
        self.on_data_received = None
        self.on_disconnect = None

What does this code do?

  • The six SEND_OPTION_ constants map to the Hazel packet types: Normal (0x00), Reliable (0x01), Hello (0x08), Disconnect (0x09), Ack (0x0A), and Ping (0x0C).
  • GAME_VERSION is the encoded integer the server expects in the Hello packet to verify the client is running a compatible version.
  • The nonce counter tracks reliable packet sequence numbers. The server uses these to acknowledge receipt.
  • pending_acks stores packets that haven't been acknowledged yet, so they can be retransmitted if needed.

You won't see a change yet. The next chunk adds the connection logic that uses these constants.

  • Add the connect() and _send_hello() methods below the __init__ method:
    async def connect(self, username: str):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setblocking(False)
        self.sock.connect((self.server_ip, self.server_port))
        await self._send_hello(username)
        self.connected = True
        asyncio.create_task(self._receive_loop())
        asyncio.create_task(self._ping_loop())

    async def _send_hello(self, username: str):
        self.nonce += 1
        name_bytes = username.encode('utf-8')
        # Hello packet: type(1) + nonce(2) + hazel_version(1) + client_version(4) + name_length(1) + name
        packet = struct.pack('>BHBi', SEND_OPTION_HELLO, self.nonce, 0, GAME_VERSION)
        packet += struct.pack('B', len(name_bytes)) + name_bytes
        self.sock.send(packet)

What does this code do?

  • connect() creates a non-blocking UDP socket and points it at the server. It sends the Hello handshake, then spawns two background tasks: one to receive packets and one to send periodic pings.
  • _send_hello() builds the Hello packet that the Impostor server expects. The format is: packet type byte, a 2-byte nonce, the Hazel version byte, the 4-byte game version integer, and the player's username.
  • socket.SOCK_DGRAM creates a UDP socket (datagrams) instead of TCP (streams). Among Us uses UDP for speed since game state updates are time-sensitive.

You won't see a change yet. The next section adds the packet sending and receiving logic.

Build packet parsing for the Hazel protocol

The Hazel protocol requires three different sending modes and a multi-branch packet router. Reliable packets get tracked with a nonce so the server can confirm receipt.

  • Add the three sending methods below _send_hello():
    async def send_reliable(self, data: bytes):
        self.nonce += 1
        packet = struct.pack('>BH', SEND_OPTION_RELIABLE, self.nonce) + data
        self.sock.send(packet)
        self.pending_acks[self.nonce] = (time.time(), packet)

    async def send_normal(self, data: bytes):
        packet = struct.pack('>B', SEND_OPTION_NORMAL) + data
        self.sock.send(packet)

    def _send_ack(self, nonce: int):
        packet = struct.pack('>BHB', SEND_OPTION_ACK, nonce, 0xFF)
        self.sock.send(packet)

What does this code do?

  • send_reliable() prepends a Reliable header (type byte + nonce) and stores the packet in pending_acks until the server acknowledges it.
  • send_normal() sends fire-and-forget packets with no delivery guarantee.
  • _send_ack() responds to the server's reliable packets. The 0xFF byte is the missing-packets bitmask indicating that no prior packets are missing.

You won't see a change yet. The next chunk adds the receive loop that actually processes incoming packets.

  • Add the receive loop and packet handler below _send_ack():
    async def _receive_loop(self):
        loop = asyncio.get_event_loop()
        while self.connected:
            try:
                data = await loop.sock_recv(self.sock, 4096)
                if data:
                    await self._handle_packet(data)
            except (ConnectionError, OSError):
                self.connected = False
                if self.on_disconnect:
                    await self.on_disconnect()
                break
            except Exception:
                await asyncio.sleep(0.01)

    async def _handle_packet(self, data: bytes):
        if len(data) < 1:
            return
        send_option = data[0]

        if send_option == SEND_OPTION_NORMAL:
            if self.on_data_received:
                await self.on_data_received(data[1:])

        elif send_option == SEND_OPTION_RELIABLE:
            if len(data) < 3:
                return
            nonce = struct.unpack('>H', data[1:3])[0]
            self._send_ack(nonce)
            if self.on_data_received:
                await self.on_data_received(data[3:])

        elif send_option == SEND_OPTION_ACK:
            if len(data) >= 3:
                nonce = struct.unpack('>H', data[1:3])[0]
                self.pending_acks.pop(nonce, None)

        elif send_option == SEND_OPTION_DISCONNECT:
            self.connected = False
            if self.on_disconnect:
                await self.on_disconnect()

        elif send_option == SEND_OPTION_PING:
            if len(data) >= 3:
                nonce = struct.unpack('>H', data[1:3])[0]
                self._send_ack(nonce)

What does this code do?

  • _receive_loop() uses Python's asyncio event loop to await incoming UDP data without blocking the rest of your program.
  • _handle_packet() reads the first byte of every packet and routes it: Normal packets pass data up, Reliable packets get acknowledged then passed up, Ack packets clear the pending queue, Disconnect triggers cleanup, and Ping packets are acknowledged to keep the connection alive.
  • The struct.unpack('>H', ...) call reads the 2-byte nonce from the packet in big-endian byte order.

You won't see a change yet. The next chunk adds the ping loop and disconnect method to complete the client.

  • Add the ping loop and disconnect method below _handle_packet():
    async def _ping_loop(self):
        while self.connected:
            await asyncio.sleep(2.0)
            self.nonce += 1
            packet = struct.pack('>BH', SEND_OPTION_PING, self.nonce)
            try:
                self.sock.send(packet)
            except OSError:
                break

    async def disconnect(self):
        if self.connected:
            packet = struct.pack('>B', SEND_OPTION_DISCONNECT)
            self.sock.send(packet)
            self.connected = False
            self.sock.close()

What does this code do?

  • _ping_loop() sends a Ping packet every 2 seconds. Without these keepalive pings, the Impostor server assumes the client dropped and kills the connection.
  • disconnect() sends a Disconnect packet to cleanly notify the server, then closes the socket.
  • Save hazel_client.py.

Seeing a syntax error when saving?

  • Check your indentation. Every method inside the HazelClient class must be indented with exactly 4 spaces.
  • Make sure the class HazelClient: line has a colon at the end.
  • Verify all parentheses and quotes are closed properly in the struct.pack calls.

My bot won't connect to the server. Can you help me find the issue?

✔️ Awesome, I've got everything!

Great. Double check you've saved hazel_client.py before moving on.

ⓧ I'd like to double check the full code

import asyncio
import struct
import socket
import time

# Hazel Protocol Constants
SEND_OPTION_NORMAL = 0x00
SEND_OPTION_RELIABLE = 0x01
SEND_OPTION_HELLO = 0x08
SEND_OPTION_DISCONNECT = 0x09
SEND_OPTION_ACK = 0x0A
SEND_OPTION_PING = 0x0C

# Among Us Game Version (17.0.0 encoded)
GAME_VERSION = 50537550

class HazelClient:
    def __init__(self, server_ip: str, server_port: int = 22023):
        self.server_ip = server_ip
        self.server_port = server_port
        self.sock = None
        self.connected = False
        self.nonce = 0
        self.pending_acks = {}
        self.on_data_received = None
        self.on_disconnect = None

    async def connect(self, username: str):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setblocking(False)
        self.sock.connect((self.server_ip, self.server_port))
        await self._send_hello(username)
        self.connected = True
        asyncio.create_task(self._receive_loop())
        asyncio.create_task(self._ping_loop())

    async def _send_hello(self, username: str):
        self.nonce += 1
        name_bytes = username.encode('utf-8')
        # Hello packet: type(1) + nonce(2) + hazel_version(1) + client_version(4) + name_length(1) + name
        packet = struct.pack('>BHBi', SEND_OPTION_HELLO, self.nonce, 0, GAME_VERSION)
        packet += struct.pack('B', len(name_bytes)) + name_bytes
        self.sock.send(packet)

    async def send_reliable(self, data: bytes):
        self.nonce += 1
        packet = struct.pack('>BH', SEND_OPTION_RELIABLE, self.nonce) + data
        self.sock.send(packet)
        self.pending_acks[self.nonce] = (time.time(), packet)

    async def send_normal(self, data: bytes):
        packet = struct.pack('>B', SEND_OPTION_NORMAL) + data
        self.sock.send(packet)

    def _send_ack(self, nonce: int):
        packet = struct.pack('>BHB', SEND_OPTION_ACK, nonce, 0xFF)
        self.sock.send(packet)

    async def _receive_loop(self):
        loop = asyncio.get_event_loop()
        while self.connected:
            try:
                data = await loop.sock_recv(self.sock, 4096)
                if data:
                    await self._handle_packet(data)
            except (ConnectionError, OSError):
                self.connected = False
                if self.on_disconnect:
                    await self.on_disconnect()
                break
            except Exception:
                await asyncio.sleep(0.01)

    async def _handle_packet(self, data: bytes):
        if len(data) < 1:
            return
        send_option = data[0]

        if send_option == SEND_OPTION_NORMAL:
            if self.on_data_received:
                await self.on_data_received(data[1:])

        elif send_option == SEND_OPTION_RELIABLE:
            if len(data) < 3:
                return
            nonce = struct.unpack('>H', data[1:3])[0]
            self._send_ack(nonce)
            if self.on_data_received:
                await self.on_data_received(data[3:])

        elif send_option == SEND_OPTION_ACK:
            if len(data) >= 3:
                nonce = struct.unpack('>H', data[1:3])[0]
                self.pending_acks.pop(nonce, None)

        elif send_option == SEND_OPTION_DISCONNECT:
            self.connected = False
            if self.on_disconnect:
                await self.on_disconnect()

        elif send_option == SEND_OPTION_PING:
            if len(data) >= 3:
                nonce = struct.unpack('>H', data[1:3])[0]
                self._send_ack(nonce)

    async def _ping_loop(self):
        while self.connected:
            await asyncio.sleep(2.0)
            self.nonce += 1
            packet = struct.pack('>BH', SEND_OPTION_PING, self.nonce)
            try:
                self.sock.send(packet)
            except OSError:
                break

    async def disconnect(self):
        if self.connected:
            packet = struct.pack('>B', SEND_OPTION_DISCONNECT)
            self.sock.send(packet)
            self.connected = False
            self.sock.close()
Connect to the Impostor server and complete the handshake

Your Hazel client is complete. Time to verify it can actually reach the Impostor server and perform the Hello handshake without errors.

  • Test the connection by running this command in your terminal:
python3 -c "
import asyncio
from hazel_client import HazelClient

async def test():
    client = HazelClient('127.0.0.1', 22023)
    await client.connect('TestBot')
    print('Connected:', client.connected)
    await asyncio.sleep(3)
    await client.disconnect()
    print('Disconnected cleanly')

asyncio.run(test())
"

You should see Connected: True followed by Disconnected cleanly after about 3 seconds.

What is happening during this test?

The test creates a HazelClient, sends a Hello packet to the Impostor server on port 22023, waits 3 seconds (during which the ping loop sends at least one keepalive), then disconnects gracefully.

If you see Connected: True, the UDP socket connected and the Hello packet was sent without the server immediately disconnecting you. This confirms the handshake succeeded.

Seeing ConnectionRefusedError or OSError?

  • Make sure the Impostor server is still running. Check with ss -ulnp | grep 22023 to confirm it's listening on that port.
  • Verify you're in the ~/among-us-bot/ directory where hazel_client.py lives.
  • Confirm your virtual environment is active. You should see (venv) in your terminal prompt.

My Hazel client test is failing with a connection error. Help me debug it.

Your bot can now speak the Hazel protocol and maintain a live connection to the server. Next up, you'll parse the game-specific messages that ride on top of Hazel to track players, game phases, and meeting events.

Implement Game State Tracking

Your Hazel client can send and receive raw bytes over UDP. But bytes alone are meaningless without structure. The Among Us protocol wraps game events (player joins, phase changes, chat messages) inside nested messages that your bot needs to decode.

In this step, you will build the game state layer that sits on top of your Hazel client. This layer decodes incoming packets into meaningful data: which players are in the lobby, what phase the game is in, and what events are happening during meetings.

In this step, get ready to:
  • Define the game state data model with player tracking and phase management.
  • Build a protocol parser that decodes Among Us messages and updates game state.
  • Handle RPC events for meetings, chat, role assignment, and voting.
Define the game state data model

Before parsing any packets, you need data structures to hold the decoded information. A game state object tracks which phase the game is in, who is playing, and what messages have been sent.

  • Create a new file called game_state.py in your ~/among-us-bot folder by running:
touch ~/among-us-bot/game_state.py
  • Open game_state.py in your text editor and add the full game state data model:
from enum import IntEnum
from dataclasses import dataclass, field

class GamePhase(IntEnum):
    LOBBY = 0
    TASKS = 1
    DISCUSSION = 2
    VOTING = 3
    ENDED = 4

class PlayerRole(IntEnum):
    CREWMATE = 0
    IMPOSTOR = 1
    UNKNOWN = 2

@dataclass
class Player:
    player_id: int
    name: str = ""
    color: int = 0
    is_alive: bool = True
    is_impostor: bool = False
    net_id: int = 0

@dataclass
class GameState:
    phase: GamePhase = GamePhase.LOBBY
    players: dict = field(default_factory=dict)
    my_player_id: int = -1
    my_role: PlayerRole = PlayerRole.UNKNOWN
    chat_messages: list = field(default_factory=list)
    meeting_caller: str = ""
    dead_body_reporter: str = ""
    vote_count: dict = field(default_factory=dict)
    game_code: str = ""

    def get_alive_players(self) -> list:
        return [p for p in self.players.values() if p.is_alive]

    def get_dead_players(self) -> list:
        return [p for p in self.players.values() if not p.is_alive]

    def get_player_names(self) -> list:
        return [p.name for p in self.players.values()]

    def get_alive_player_names(self) -> list:
        return [p.name for p in self.players.values() if p.is_alive]

    def reset_for_new_game(self):
        self.phase = GamePhase.LOBBY
        self.my_role = PlayerRole.UNKNOWN
        self.chat_messages.clear()
        self.vote_count.clear()
        for player in self.players.values():
            player.is_alive = True
            player.is_impostor = False

What does this code do?

This file defines the data structures your bot uses to understand the game world.

  • GamePhase tracks where the game currently is: lobby, tasks (gameplay), discussion, voting, or ended.
  • PlayerRole records whether your bot is a Crewmate, Impostor, or hasn't been assigned yet.
  • Player stores per-player info: their ID, name, alive/dead status, and network ID for sending packets to them.
  • GameState is the central object holding all players, the current phase, chat history, and helper methods to query who is alive or dead.
  • Save game_state.py.
  • Verify the file is valid Python by running:
python game_state.py

You should see no output and no errors. That confirms the module loads cleanly.

Seeing a SyntaxError?

Make sure your virtual environment is activated (you should see (venv) in your prompt). The dataclass decorator and field() function require Python 3.7+.

Check that each class and method is indented consistently with spaces, not tabs.

help me debug a SyntaxError in my game_state.py dataclass definitions

✔️ Awesome, I've got everything!

Great. Make sure you have saved game_state.py before moving on.

ⓧ I'd like to double check the full code

from enum import IntEnum
from dataclasses import dataclass, field

class GamePhase(IntEnum):
    LOBBY = 0
    TASKS = 1
    DISCUSSION = 2
    VOTING = 3
    ENDED = 4

class PlayerRole(IntEnum):
    CREWMATE = 0
    IMPOSTOR = 1
    UNKNOWN = 2

@dataclass
class Player:
    player_id: int
    name: str = ""
    color: int = 0
    is_alive: bool = True
    is_impostor: bool = False
    net_id: int = 0

@dataclass
class GameState:
    phase: GamePhase = GamePhase.LOBBY
    players: dict = field(default_factory=dict)
    my_player_id: int = -1
    my_role: PlayerRole = PlayerRole.UNKNOWN
    chat_messages: list = field(default_factory=list)
    meeting_caller: str = ""
    dead_body_reporter: str = ""
    vote_count: dict = field(default_factory=dict)
    game_code: str = ""

    def get_alive_players(self) -> list:
        return [p for p in self.players.values() if p.is_alive]

    def get_dead_players(self) -> list:
        return [p for p in self.players.values() if not p.is_alive]

    def get_player_names(self) -> list:
        return [p.name for p in self.players.values()]

    def get_alive_player_names(self) -> list:
        return [p.name for p in self.players.values() if p.is_alive]

    def reset_for_new_game(self):
        self.phase = GamePhase.LOBBY
        self.my_role = PlayerRole.UNKNOWN
        self.chat_messages.clear()
        self.vote_count.clear()
        for player in self.players.values():
            player.is_alive = True
            player.is_impostor = False
Track game phase changes

Now you need a parser that reads incoming Among Us protocol messages and updates your game state. The protocol uses nested messages: a root message contains inner GameData messages, which in turn contain RPC calls or spawn events.

  • Create among_us_protocol.py in your ~/among-us-bot folder by running:
touch ~/among-us-bot/among_us_protocol.py
  • Open among_us_protocol.py and add the protocol constants and helper functions:
import struct
from game_state import GameState, GamePhase, Player, PlayerRole

# Root Message Types
MSG_HOST_GAME = 0x00
MSG_JOIN_GAME = 0x01
MSG_START_GAME = 0x02
MSG_GAME_DATA = 0x05
MSG_GAME_DATA_TO = 0x06
MSG_JOINED_GAME = 0x07
MSG_END_GAME = 0x08
MSG_REDIRECT = 0x0D

# GameData sub-types
GAMEDATA_RPC = 0x02
GAMEDATA_SPAWN = 0x04
GAMEDATA_SCENE_CHANGE = 0x06

# RPC Types
RPC_SEND_CHAT = 0x0D
RPC_START_MEETING = 0x0E
RPC_SET_INFECTED = 0x03
RPC_VOTING_COMPLETE = 0x17
RPC_CAST_VOTE = 0x18
RPC_MURDER_PLAYER = 0x0C
RPC_EXILED = 0x04
RPC_SET_NAME = 0x06
RPC_SET_COLOR = 0x08

def read_packed_int(data: bytes, offset: int) -> tuple:
    result = 0
    shift = 0
    while offset < len(data):
        byte = data[offset]
        offset += 1
        result |= (byte & 0x7F) << shift
        if (byte & 0x80) == 0:
            break
        shift += 7
    return result, offset

def write_packed_int(value: int) -> bytes:
    result = bytearray()
    while value > 0x7F:
        result.append((value & 0x7F) | 0x80)
        value >>= 7
    result.append(value & 0x7F)
    return bytes(result) if result else b'\x00'

What does this code do?

The constants at the top map hex values to the message types the Among Us protocol uses. When a packet arrives with tag 0x05, you know it is a MSG_GAME_DATA message containing game events.

The read_packed_int and write_packed_int functions handle variable-length integer encoding. Among Us uses this encoding to save bandwidth by storing small numbers in fewer bytes.

  • Save the file, then continue adding the packet building and game code functions below the write_packed_int function:
def build_join_game_packet(game_code: str) -> bytes:
    code_int = game_code_to_int(game_code)
    # Hazel message: length(2) + tag(1) + payload
    payload = struct.pack('<i', code_int)
    # Map ownership flags
    payload += struct.pack('<B', 0x07)
    message = struct.pack('<HB', len(payload), MSG_JOIN_GAME) + payload
    return message

def build_chat_message(net_id: int, message: str) -> bytes:
    msg_bytes = message.encode('utf-8')
    # RPC payload: net_id(packed) + rpc_type(1) + string_length(packed) + string
    rpc_payload = write_packed_int(net_id)
    rpc_payload += struct.pack('B', RPC_SEND_CHAT)
    rpc_payload += write_packed_int(len(msg_bytes))
    rpc_payload += msg_bytes
    # Wrap in GameData message
    gamedata_msg = struct.pack('<HB', len(rpc_payload), GAMEDATA_RPC) + rpc_payload
    # Wrap in root message
    game_code_bytes = struct.pack('<i', 0)  # placeholder game code
    root_payload = game_code_bytes + gamedata_msg
    root_msg = struct.pack('<HB', len(root_payload), MSG_GAME_DATA) + root_payload
    return root_msg

def build_vote_packet(net_id: int, voter_id: int, suspect_id: int) -> bytes:
    # RPC payload for CastVote
    rpc_payload = write_packed_int(net_id)
    rpc_payload += struct.pack('B', RPC_CAST_VOTE)
    rpc_payload += struct.pack('BB', voter_id, suspect_id)
    # Wrap in GameData message
    gamedata_msg = struct.pack('<HB', len(rpc_payload), GAMEDATA_RPC) + rpc_payload
    game_code_bytes = struct.pack('<i', 0)
    root_payload = game_code_bytes + gamedata_msg
    root_msg = struct.pack('<HB', len(root_payload), MSG_GAME_DATA) + root_payload
    return root_msg

def game_code_to_int(code: str) -> int:
    code = code.upper()
    if len(code) == 6:
        charset = "QWXRTYLPESDFGHUJKZOCVBINMA"
        a = charset.index(code[0])
        b = charset.index(code[1])
        c = charset.index(code[2])
        d = charset.index(code[3])
        e = charset.index(code[4])
        f = charset.index(code[5])
        first = (a + 26 * b) & 0x3FF
        second = c + 26 * (d + 26 * (e + 26 * f))
        return first | ((second << 10) & 0x3FFFFC00)
    elif len(code) == 4:
        return struct.unpack('<i', code.encode('utf-8'))[0]
    return 0

def int_to_game_code(code_int: int) -> str:
    if code_int < 0:
        charset = "QWXRTYLPESDFGHUJKZOCVBINMA"
        first = code_int & 0x3FF
        second = (code_int >> 10) & 0xFFFFF
        a = first % 26
        b = first // 26
        c = second % 26
        rem = second // 26
        d = rem % 26
        rem2 = rem // 26
        e = rem2 % 26
        f = rem2 // 26
        return charset[a] + charset[b] + charset[c] + charset[d] + charset[e] + charset[f]
    else:
        return struct.pack('<i', code_int).decode('utf-8').rstrip('\x00')

What does this code do?

These functions build the outgoing packets your bot sends to the server and handle game code encoding.

  • build_join_game_packet constructs the message that requests to join a specific lobby by its game code.
  • build_chat_message wraps a chat string in the correct RPC format so the server broadcasts it to all players.
  • build_vote_packet sends a vote targeting a specific player during the voting phase.
  • game_code_to_int and int_to_game_code convert between the 6-character lobby codes (like ABCDEF) and the integer encoding the protocol uses internally.

Why V2 game codes?

Among Us switched from 4-character to 6-character game codes in a previous update. The V2 encoding maps 6 letters to a 32-bit integer using a custom character set (QWXRTYLPESDFGHUJKZOCVBINMA). The code handles both formats for backwards compatibility.

  • Save the file. Now add the ProtocolParser class below the int_to_game_code function. This class parses incoming messages and updates your game state:
class ProtocolParser:
    def __init__(self, game_state: GameState):
        self.state = game_state

    def parse_message(self, data: bytes):
        offset = 0
        while offset < len(data):
            if offset + 3 > len(data):
                break
            length = struct.unpack_from('<H', data, offset)[0]
            tag = data[offset + 2]
            offset += 3
            payload = data[offset:offset + length]
            offset += length
            self._handle_root_message(tag, payload)

    def _handle_root_message(self, tag: int, payload: bytes):
        if tag == MSG_JOINED_GAME:
            if len(payload) >= 8:
                game_code = struct.unpack_from('<i', payload, 0)[0]
                self.state.game_code = int_to_game_code(game_code)
                self.state.my_player_id = struct.unpack_from('<i', payload, 4)[0]

        elif tag == MSG_START_GAME:
            self.state.phase = GamePhase.TASKS

        elif tag == MSG_END_GAME:
            self.state.phase = GamePhase.ENDED

        elif tag in (MSG_GAME_DATA, MSG_GAME_DATA_TO):
            inner_offset = 4 if len(payload) > 4 else 0
            while inner_offset < len(payload):
                if inner_offset + 3 > len(payload):
                    break
                inner_len = struct.unpack_from('<H', payload, inner_offset)[0]
                inner_tag = payload[inner_offset + 2]
                inner_offset += 3
                inner_payload = payload[inner_offset:inner_offset + inner_len]
                inner_offset += inner_len
                self._handle_gamedata(inner_tag, inner_payload)

        elif tag == MSG_REDIRECT:
            pass

    def _handle_gamedata(self, tag: int, payload: bytes):
        if tag == GAMEDATA_RPC:
            self._handle_rpc(payload)
        elif tag == GAMEDATA_SPAWN:
            self._handle_spawn(payload)

    def _handle_spawn(self, payload: bytes):
        if len(payload) < 2:
            return
        spawn_type, offset = read_packed_int(payload, 0)
        if offset >= len(payload):
            return
        owner_id = struct.unpack_from('<i', payload, offset)[0] if offset + 4 <= len(payload) else -1
        offset += 4
        # PlayerControl spawn type is 0x04
        if spawn_type == 0x04 and owner_id >= 0:
            if owner_id not in self.state.players:
                self.state.players[owner_id] = Player(player_id=owner_id)
            # Read net_id from spawn
            if offset < len(payload):
                spawn_net_id, offset = read_packed_int(payload, offset)
                self.state.players[owner_id].net_id = spawn_net_id

What does this code do?

The ProtocolParser reads raw bytes from the server and updates the GameState object in real time.

  • parse_message splits a buffer into individual root messages using little-endian length + tag headers.
  • _handle_root_message routes messages by type: MSG_START_GAME transitions the phase to TASKS, MSG_END_GAME transitions to ENDED, and MSG_GAME_DATA is unpacked further.
  • _handle_spawn detects PlayerControl spawn packets (type 0x04) and registers each new player in the state dictionary with their network ID.
  • Save among_us_protocol.py.
  • Verify the file loads without errors by running:
python among_us_protocol.py

You should see no output. The parser class is ready, but the _handle_rpc method does not exist yet. You will add it next.

Seeing an ImportError for game_state?

Make sure game_state.py is in the same directory as among_us_protocol.py. Both files must live in ~/among-us-bot/. Run ls to confirm both files are present.

help me fix an ImportError when among_us_protocol.py tries to import from game_state

Monitor player status and meeting events

The parser framework routes messages to the right handler, but the RPC handler is missing. RPC (Remote Procedure Call) messages carry the most important game events: role assignments, meeting starts, chat messages, and vote results.

  • In among_us_protocol.py, add the _handle_rpc method inside the ProtocolParser class, between _handle_gamedata and _handle_spawn:
    def _handle_rpc(self, payload: bytes):
        if len(payload) < 2:
            return
        net_id, offset = read_packed_int(payload, 0)
        if offset >= len(payload):
            return
        rpc_type = payload[offset]
        rpc_data = payload[offset + 1:]

        if rpc_type == RPC_SET_INFECTED:
            # Impostor assignment
            if len(rpc_data) > 0:
                count, off = read_packed_int(rpc_data, 0)
                for i in range(count):
                    if off < len(rpc_data):
                        impostor_id = rpc_data[off]
                        off += 1
                        if impostor_id == self.state.my_player_id:
                            self.state.my_role = PlayerRole.IMPOSTOR
                        if impostor_id in self.state.players:
                            self.state.players[impostor_id].is_impostor = True
                if self.state.my_role != PlayerRole.IMPOSTOR:
                    self.state.my_role = PlayerRole.CREWMATE

        elif rpc_type == RPC_START_MEETING:
            self.state.phase = GamePhase.DISCUSSION

        elif rpc_type == RPC_VOTING_COMPLETE:
            self.state.phase = GamePhase.TASKS

        elif rpc_type == RPC_SEND_CHAT:
            if len(rpc_data) > 0:
                str_len, off = read_packed_int(rpc_data, 0)
                msg = rpc_data[off:off + str_len].decode('utf-8', errors='ignore')
                # Find sender by net_id
                sender = f"Player({net_id})"
                for p in self.state.players.values():
                    if p.net_id == net_id:
                        sender = p.name
                        break
                self.state.chat_messages.append({"sender": sender, "message": msg})

        elif rpc_type == RPC_MURDER_PLAYER:
            pass  # Track kills

        elif rpc_type == RPC_EXILED:
            pass  # Track ejections

        elif rpc_type == RPC_SET_NAME:
            if len(rpc_data) > 0:
                str_len, off = read_packed_int(rpc_data, 0)
                name = rpc_data[off:off + str_len].decode('utf-8', errors='ignore')
                # Associate name with net_id
                for p in self.state.players.values():
                    if p.net_id == net_id:
                        p.name = name
                        break

What does this code do?

This method dispatches RPC events to update the game state in real time.

  • RPC_SET_INFECTED fires at game start and tells the bot whether it is Impostor or Crewmate.
  • RPC_START_MEETING transitions the phase to DISCUSSION, signaling the bot to prepare chat and votes.
  • RPC_SEND_CHAT captures chat messages from other players. The bot logs these to build context for its LLM responses.
  • RPC_SET_NAME associates a human-readable name with a player's network ID, so chat logs show names instead of numbers.
  • Save among_us_protocol.py.
  • Confirm everything loads cleanly by running:
python -c "from among_us_protocol import ProtocolParser, build_join_game_packet; print('Protocol module loaded OK')"

You should see Protocol module loaded OK printed to the terminal.

Seeing an AttributeError or IndentationError?

The _handle_rpc method must be indented one level inside the ProtocolParser class (4 spaces). Make sure it sits between _handle_gamedata and _handle_spawn, not outside the class.

help me fix an error when importing the ProtocolParser class from among_us_protocol.py

✔️ Awesome, I've got everything!

Great. Double check you have saved among_us_protocol.py with all sections in place.

ⓧ I'd like to double check the full code

import struct
from game_state import GameState, GamePhase, Player, PlayerRole

# Root Message Types
MSG_HOST_GAME = 0x00
MSG_JOIN_GAME = 0x01
MSG_START_GAME = 0x02
MSG_GAME_DATA = 0x05
MSG_GAME_DATA_TO = 0x06
MSG_JOINED_GAME = 0x07
MSG_END_GAME = 0x08
MSG_REDIRECT = 0x0D

# GameData sub-types
GAMEDATA_RPC = 0x02
GAMEDATA_SPAWN = 0x04
GAMEDATA_SCENE_CHANGE = 0x06

# RPC Types
RPC_SEND_CHAT = 0x0D
RPC_START_MEETING = 0x0E
RPC_SET_INFECTED = 0x03
RPC_VOTING_COMPLETE = 0x17
RPC_CAST_VOTE = 0x18
RPC_MURDER_PLAYER = 0x0C
RPC_EXILED = 0x04
RPC_SET_NAME = 0x06
RPC_SET_COLOR = 0x08

def read_packed_int(data: bytes, offset: int) -> tuple:
    result = 0
    shift = 0
    while offset < len(data):
        byte = data[offset]
        offset += 1
        result |= (byte & 0x7F) << shift
        if (byte & 0x80) == 0:
            break
        shift += 7
    return result, offset

def write_packed_int(value: int) -> bytes:
    result = bytearray()
    while value > 0x7F:
        result.append((value & 0x7F) | 0x80)
        value >>= 7
    result.append(value & 0x7F)
    return bytes(result) if result else b'\x00'

def build_join_game_packet(game_code: str) -> bytes:
    code_int = game_code_to_int(game_code)
    # Hazel message: length(2) + tag(1) + payload
    payload = struct.pack('<i', code_int)
    # Map ownership flags
    payload += struct.pack('<B', 0x07)
    message = struct.pack('<HB', len(payload), MSG_JOIN_GAME) + payload
    return message

def build_chat_message(net_id: int, message: str) -> bytes:
    msg_bytes = message.encode('utf-8')
    # RPC payload: net_id(packed) + rpc_type(1) + string_length(packed) + string
    rpc_payload = write_packed_int(net_id)
    rpc_payload += struct.pack('B', RPC_SEND_CHAT)
    rpc_payload += write_packed_int(len(msg_bytes))
    rpc_payload += msg_bytes
    # Wrap in GameData message
    gamedata_msg = struct.pack('<HB', len(rpc_payload), GAMEDATA_RPC) + rpc_payload
    # Wrap in root message
    game_code_bytes = struct.pack('<i', 0)  # placeholder game code
    root_payload = game_code_bytes + gamedata_msg
    root_msg = struct.pack('<HB', len(root_payload), MSG_GAME_DATA) + root_payload
    return root_msg

def build_vote_packet(net_id: int, voter_id: int, suspect_id: int) -> bytes:
    # RPC payload for CastVote
    rpc_payload = write_packed_int(net_id)
    rpc_payload += struct.pack('B', RPC_CAST_VOTE)
    rpc_payload += struct.pack('BB', voter_id, suspect_id)
    # Wrap in GameData message
    gamedata_msg = struct.pack('<HB', len(rpc_payload), GAMEDATA_RPC) + rpc_payload
    game_code_bytes = struct.pack('<i', 0)
    root_payload = game_code_bytes + gamedata_msg
    root_msg = struct.pack('<HB', len(root_payload), MSG_GAME_DATA) + root_payload
    return root_msg

def game_code_to_int(code: str) -> int:
    code = code.upper()
    if len(code) == 6:
        charset = "QWXRTYLPESDFGHUJKZOCVBINMA"
        a = charset.index(code[0])
        b = charset.index(code[1])
        c = charset.index(code[2])
        d = charset.index(code[3])
        e = charset.index(code[4])
        f = charset.index(code[5])
        first = (a + 26 * b) & 0x3FF
        second = c + 26 * (d + 26 * (e + 26 * f))
        return first | ((second << 10) & 0x3FFFFC00)
    elif len(code) == 4:
        return struct.unpack('<i', code.encode('utf-8'))[0]
    return 0

def int_to_game_code(code_int: int) -> str:
    if code_int < 0:
        charset = "QWXRTYLPESDFGHUJKZOCVBINMA"
        first = code_int & 0x3FF
        second = (code_int >> 10) & 0xFFFFF
        a = first % 26
        b = first // 26
        c = second % 26
        rem = second // 26
        d = rem % 26
        rem2 = rem // 26
        e = rem2 % 26
        f = rem2 // 26
        return charset[a] + charset[b] + charset[c] + charset[d] + charset[e] + charset[f]
    else:
        return struct.pack('<i', code_int).decode('utf-8').rstrip('\x00')

class ProtocolParser:
    def __init__(self, game_state: GameState):
        self.state = game_state

    def parse_message(self, data: bytes):
        offset = 0
        while offset < len(data):
            if offset + 3 > len(data):
                break
            length = struct.unpack_from('<H', data, offset)[0]
            tag = data[offset + 2]
            offset += 3
            payload = data[offset:offset + length]
            offset += length
            self._handle_root_message(tag, payload)

    def _handle_root_message(self, tag: int, payload: bytes):
        if tag == MSG_JOINED_GAME:
            if len(payload) >= 8:
                game_code = struct.unpack_from('<i', payload, 0)[0]
                self.state.game_code = int_to_game_code(game_code)
                self.state.my_player_id = struct.unpack_from('<i', payload, 4)[0]

        elif tag == MSG_START_GAME:
            self.state.phase = GamePhase.TASKS

        elif tag == MSG_END_GAME:
            self.state.phase = GamePhase.ENDED

        elif tag in (MSG_GAME_DATA, MSG_GAME_DATA_TO):
            inner_offset = 4 if len(payload) > 4 else 0
            while inner_offset < len(payload):
                if inner_offset + 3 > len(payload):
                    break
                inner_len = struct.unpack_from('<H', payload, inner_offset)[0]
                inner_tag = payload[inner_offset + 2]
                inner_offset += 3
                inner_payload = payload[inner_offset:inner_offset + inner_len]
                inner_offset += inner_len
                self._handle_gamedata(inner_tag, inner_payload)

        elif tag == MSG_REDIRECT:
            pass

    def _handle_gamedata(self, tag: int, payload: bytes):
        if tag == GAMEDATA_RPC:
            self._handle_rpc(payload)
        elif tag == GAMEDATA_SPAWN:
            self._handle_spawn(payload)

    def _handle_rpc(self, payload: bytes):
        if len(payload) < 2:
            return
        net_id, offset = read_packed_int(payload, 0)
        if offset >= len(payload):
            return
        rpc_type = payload[offset]
        rpc_data = payload[offset + 1:]

        if rpc_type == RPC_SET_INFECTED:
            if len(rpc_data) > 0:
                count, off = read_packed_int(rpc_data, 0)
                for i in range(count):
                    if off < len(rpc_data):
                        impostor_id = rpc_data[off]
                        off += 1
                        if impostor_id == self.state.my_player_id:
                            self.state.my_role = PlayerRole.IMPOSTOR
                        if impostor_id in self.state.players:
                            self.state.players[impostor_id].is_impostor = True
                if self.state.my_role != PlayerRole.IMPOSTOR:
                    self.state.my_role = PlayerRole.CREWMATE

        elif rpc_type == RPC_START_MEETING:
            self.state.phase = GamePhase.DISCUSSION

        elif rpc_type == RPC_VOTING_COMPLETE:
            self.state.phase = GamePhase.TASKS

        elif rpc_type == RPC_SEND_CHAT:
            if len(rpc_data) > 0:
                str_len, off = read_packed_int(rpc_data, 0)
                msg = rpc_data[off:off + str_len].decode('utf-8', errors='ignore')
                sender = f"Player({net_id})"
                for p in self.state.players.values():
                    if p.net_id == net_id:
                        sender = p.name
                        break
                self.state.chat_messages.append({"sender": sender, "message": msg})

        elif rpc_type == RPC_MURDER_PLAYER:
            pass  # Track kills

        elif rpc_type == RPC_EXILED:
            pass  # Track ejections

        elif rpc_type == RPC_SET_NAME:
            if len(rpc_data) > 0:
                str_len, off = read_packed_int(rpc_data, 0)
                name = rpc_data[off:off + str_len].decode('utf-8', errors='ignore')
                for p in self.state.players.values():
                    if p.net_id == net_id:
                        p.name = name
                        break

    def _handle_spawn(self, payload: bytes):
        if len(payload) < 2:
            return
        spawn_type, offset = read_packed_int(payload, 0)
        if offset >= len(payload):
            return
        owner_id = struct.unpack_from('<i', payload, offset)[0] if offset + 4 <= len(payload) else -1
        offset += 4
        if spawn_type == 0x04 and owner_id >= 0:
            if owner_id not in self.state.players:
                self.state.players[owner_id] = Player(player_id=owner_id)
            if offset < len(payload):
                spawn_net_id, offset = read_packed_int(payload, offset)
                self.state.players[owner_id].net_id = spawn_net_id

Your game state layer is complete. The parser can decode incoming packets, identify players, track phase transitions, and capture meeting chat. Next up, you will build the AI brain that uses this game state to generate chat messages and cast votes during meetings.

Build the AI Meeting Brain

Your bot can now connect to the server and track everything happening in a game. It knows who's alive, what phase the game is in, and when a meeting starts.

But knowing what's happening isn't the same as knowing what to say. During meetings, real players argue, accuse, and bluff. Your bot needs a brain that can read the room and respond convincingly. In this step, you'll build an AI decision engine powered by Ollama that generates meeting chat and casts strategic votes.

In this step, get ready to:
  • Create an Ollama-powered decision engine that queries your local LLM.
  • Design context-aware prompts that feed the bot game state information.
  • Implement chat message generation and vote casting logic.
Create the Ollama-powered decision engine

Every time a meeting is called, the bot needs to talk to your local LLM and get a response back. This requires a helper function that sends prompts to Ollama's HTTP API and returns the generated text.

  • Create a new file called ai_brain.py inside your ~/among-us-bot folder by running:
touch ~/among-us-bot/ai_brain.py
  • Open ai_brain.py in your text editor and add the imports, configuration constants, and the core Ollama query helper:
import requests
import json

# Ollama API endpoint and model configuration
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "gemma2:2b"

def _query_ollama(prompt: str) -> str:
    try:
        response = requests.post(
            OLLAMA_URL,
            json={
                "model": MODEL_NAME,
                "prompt": prompt,
                "stream": False,
                "options": {
                    "temperature": 0.8,
                    "num_predict": 60,
                    "num_ctx": 2048
                }
            },
            timeout=30
        )
        if response.status_code == 200:
            return response.json().get("response", "")
    except requests.exceptions.RequestException:
        pass
    return ""

What does this code do?

OLLAMA_URL points to the local Ollama server's generate endpoint. MODEL_NAME selects gemma2:2b, which fits comfortably in your 4GB VRAM.

The _query_ollama function POSTs a prompt to the API with stream: False so it waits for the full response. The options keep VRAM usage low: num_predict=60 limits output tokens (short chat messages), and num_ctx=2048 halves the default context buffer size.

  • Save ai_brain.py.
  • Test the helper by running this quick command in your terminal:
cd ~/among-us-bot && python3 -c "from ai_brain import _query_ollama; print(_query_ollama('Say hello in 5 words'))"

You should see a short greeting printed by the LLM. It may take a few seconds on the first call as the model loads into VRAM.

Getting an empty response or connection error?

  • Make sure Ollama is still running. Run ollama list to confirm gemma2:2b is available.
  • Check that your virtual environment is activated (you should see (venv) in your prompt). The requests library is installed there.
  • If you see a timeout, the model may be loading. Wait 10 seconds and try again.

help me debug why my Ollama API call is returning empty or timing out

Design the meeting chat prompt

When a meeting starts, the bot needs to generate a convincing chat message. The quality of that message depends entirely on the prompt you feed the LLM. A good prompt packs in game context: who's alive, who's dead, what others are saying, and whether the bot is a Crewmate or Impostor.

  • In ai_brain.py, add the generate_meeting_chat function above the _query_ollama function:
def generate_meeting_chat(game_context: dict) -> str:
    role = game_context.get("my_role", "crewmate")
    alive_players = game_context.get("alive_players", [])
    recent_chat = game_context.get("recent_chat", [])
    dead_players = game_context.get("dead_players", [])
    my_name = game_context.get("my_name", "Bot")

    # Build a readable chat history from recent messages
    chat_history = ""
    for msg in recent_chat[-5:]:
        chat_history += f"  {msg['sender']}: {msg['message']}\n"

    if role == "crewmate":
        prompt = f"""You are {my_name}, a player in Among Us. You are a CREWMATE (innocent).
A meeting was called. You need to figure out who the Impostor is.

Alive players: {', '.join(alive_players)}
Dead players: {', '.join(dead_players) if dead_players else 'None yet'}

Recent chat:
{chat_history if chat_history else '  (no messages yet)'}

Write ONE short message (under 50 characters) for the meeting chat. Be suspicious of someone or defend yourself. Stay in character. Do not use quotes. Just write the message text."""
    else:
        prompt = f"""You are {my_name}, a player in Among Us. You are the IMPOSTOR (evil) but must pretend to be innocent.

Alive players: {', '.join(alive_players)}
Dead players: {', '.join(dead_players) if dead_players else 'None yet'}

Recent chat:
{chat_history if chat_history else '  (no messages yet)'}

Write ONE short message (under 50 characters) for the meeting chat. You must deflect suspicion from yourself. Blame someone else or act confused. Stay in character. Do not use quotes. Just write the message text."""

    # Query the LLM and truncate to 100 chars max
    response = _query_ollama(prompt)
    return response.strip()[:100] if response else "I was doing tasks"

What does this code do?

The function unpacks a game_context dictionary containing the bot's current game state: its role, who's alive, who's dead, its name, and recent chat messages.

It builds a different prompt depending on whether the bot is Crewmate or Impostor. The Crewmate prompt asks the LLM to be suspicious and find the killer. The Impostor prompt asks it to deflect blame and act innocent.

The response is truncated to 100 characters and falls back to a safe default if the LLM fails to respond.

  • Save ai_brain.py.
  • Test the chat generation with a fake game context by running:
cd ~/among-us-bot && python3 -c "
from ai_brain import generate_meeting_chat
ctx = {
    'my_role': 'crewmate',
    'alive_players': ['Bot', 'Alice', 'Bob', 'Charlie'],
    'dead_players': ['Dave'],
    'recent_chat': [{'sender': 'Alice', 'message': 'where was everyone'}],
    'my_name': 'Bot'
}
print(generate_meeting_chat(ctx))
"

You should see a short, in-character chat message like an accusation or defense. Each run produces different output due to the temperature setting.

Getting a generic or empty response?

  • If you see I was doing tasks, the LLM call failed silently. Check that Ollama is running and gemma2:2b is loaded.
  • If the response is too long or includes quotes, that's normal variance from the LLM. The [:100] truncation keeps it safe for the game chat.

help me debug why generate_meeting_chat returns the fallback message

Implement the vote decision logic

Chatting is only half the battle. After discussion, the bot needs to cast a vote. The voting logic asks the LLM to pick a player name from the alive list, then validates the response against actual player names to prevent hallucinated votes.

  • In ai_brain.py, add the decide_vote function between generate_meeting_chat and _query_ollama:
def decide_vote(game_context: dict) -> str:
    role = game_context.get("my_role", "crewmate")
    alive_players = game_context.get("alive_players", [])
    recent_chat = game_context.get("recent_chat", [])
    my_name = game_context.get("my_name", "Bot")

    # Summarize recent chat for voting context
    chat_summary = ""
    for msg in recent_chat[-8:]:
        chat_summary += f"  {msg['sender']}: {msg['message']}\n"

    others = [p for p in alive_players if p != my_name]

    if role == "crewmate":
        prompt = f"""You are {my_name} in Among Us. You are a CREWMATE. Time to vote.

Alive players (excluding you): {', '.join(others)}
Chat this round:
{chat_summary if chat_summary else '  (silent round)'}

Who do you vote for? Pick ONE name from the alive players list, or say "skip" to skip vote.
Reply with ONLY the player name or "skip". Nothing else."""
    else:
        prompt = f"""You are {my_name} in Among Us. You are the IMPOSTOR. Time to vote.

Alive players (excluding you): {', '.join(others)}
Chat this round:
{chat_summary if chat_summary else '  (silent round)'}

Who do you vote for to avoid suspicion? Pick ONE name from the alive players list that others seem to suspect, or say "skip".
Reply with ONLY the player name or "skip". Nothing else."""

    response = _query_ollama(prompt)
    vote = response.strip().split('\n')[0].strip()

    # Validate vote against actual player names
    if vote.lower() == "skip":
        return "skip"
    for player in others:
        if player.lower() in vote.lower():
            return player
    return "skip"

What does this code do?

Like the chat function, decide_vote builds a role-specific prompt. The Crewmate prompt asks who seems suspicious. The Impostor prompt asks who to vote for to blend in with the group.

The validation loop at the end is critical. LLMs can return unexpected text, so the function checks whether any alive player's name appears in the response. If the LLM hallucinates a non-existent name, the bot defaults to a safe skip vote instead of crashing.

  • Save ai_brain.py.
  • Test the voting logic by running:
cd ~/among-us-bot && python3 -c "
from ai_brain import decide_vote
ctx = {
    'my_role': 'crewmate',
    'alive_players': ['Bot', 'Alice', 'Bob', 'Charlie'],
    'recent_chat': [{'sender': 'Alice', 'message': 'Bob was acting sus near electrical'}],
    'my_name': 'Bot'
}
print('Vote:', decide_vote(ctx))
"

You should see a player name (like Bob or Alice) or skip printed. The LLM uses chat context to make its decision.

Always getting skip as the vote?

  • This means the LLM's response didn't match any player name in the alive list. Run the test a few more times. The temperature of 0.8 introduces randomness.
  • If it consistently skips, Ollama might be returning empty strings. Verify with ollama run gemma2:2b "say hello" that the model responds interactively.

help me figure out why my bot always votes skip

✔️ Awesome, I've got everything!

Great. Double check you've saved ai_brain.py with all three functions in order: generate_meeting_chat, decide_vote, then _query_ollama at the bottom.

ⓧ I'd like to double check the full code

Here's the complete ai_brain.py file:

import requests
import json

OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "gemma2:2b"

def generate_meeting_chat(game_context: dict) -> str:
    role = game_context.get("my_role", "crewmate")
    alive_players = game_context.get("alive_players", [])
    recent_chat = game_context.get("recent_chat", [])
    dead_players = game_context.get("dead_players", [])
    my_name = game_context.get("my_name", "Bot")

    chat_history = ""
    for msg in recent_chat[-5:]:
        chat_history += f"  {msg['sender']}: {msg['message']}\n"

    if role == "crewmate":
        prompt = f"""You are {my_name}, a player in Among Us. You are a CREWMATE (innocent).
A meeting was called. You need to figure out who the Impostor is.

Alive players: {', '.join(alive_players)}
Dead players: {', '.join(dead_players) if dead_players else 'None yet'}

Recent chat:
{chat_history if chat_history else '  (no messages yet)'}

Write ONE short message (under 50 characters) for the meeting chat. Be suspicious of someone or defend yourself. Stay in character. Do not use quotes. Just write the message text."""
    else:
        prompt = f"""You are {my_name}, a player in Among Us. You are the IMPOSTOR (evil) but must pretend to be innocent.

Alive players: {', '.join(alive_players)}
Dead players: {', '.join(dead_players) if dead_players else 'None yet'}

Recent chat:
{chat_history if chat_history else '  (no messages yet)'}

Write ONE short message (under 50 characters) for the meeting chat. You must deflect suspicion from yourself. Blame someone else or act confused. Stay in character. Do not use quotes. Just write the message text."""

    response = _query_ollama(prompt)
    return response.strip()[:100] if response else "I was doing tasks"

def decide_vote(game_context: dict) -> str:
    role = game_context.get("my_role", "crewmate")
    alive_players = game_context.get("alive_players", [])
    recent_chat = game_context.get("recent_chat", [])
    my_name = game_context.get("my_name", "Bot")

    chat_summary = ""
    for msg in recent_chat[-8:]:
        chat_summary += f"  {msg['sender']}: {msg['message']}\n"

    others = [p for p in alive_players if p != my_name]

    if role == "crewmate":
        prompt = f"""You are {my_name} in Among Us. You are a CREWMATE. Time to vote.

Alive players (excluding you): {', '.join(others)}
Chat this round:
{chat_summary if chat_summary else '  (silent round)'}

Who do you vote for? Pick ONE name from the alive players list, or say "skip" to skip vote.
Reply with ONLY the player name or "skip". Nothing else."""
    else:
        prompt = f"""You are {my_name} in Among Us. You are the IMPOSTOR. Time to vote.

Alive players (excluding you): {', '.join(others)}
Chat this round:
{chat_summary if chat_summary else '  (silent round)'}

Who do you vote for to avoid suspicion? Pick ONE name from the alive players list that others seem to suspect, or say "skip".
Reply with ONLY the player name or "skip". Nothing else."""

    response = _query_ollama(prompt)
    vote = response.strip().split('\n')[0].strip()

    # Validate vote
    if vote.lower() == "skip":
        return "skip"
    for player in others:
        if player.lower() in vote.lower():
            return player
    return "skip"

def _query_ollama(prompt: str) -> str:
    try:
        response = requests.post(
            OLLAMA_URL,
            json={
                "model": MODEL_NAME,
                "prompt": prompt,
                "stream": False,
                "options": {
                    "temperature": 0.8,
                    "num_predict": 60,
                    "num_ctx": 2048
                }
            },
            timeout=30
        )
        if response.status_code == 200:
            return response.json().get("response", "")
    except requests.exceptions.RequestException:
        pass
    return ""

Your bot now has a working brain. It can read game context, generate meeting chat that sounds like a real player, and cast votes based on discussion. Next up, you'll wire the protocol client, state tracker, and AI brain together into a single autonomous agent loop.

Wire It All Together and Test

You have a protocol client that speaks Hazel UDP, a state tracker that monitors game events, and an AI brain that generates chat and votes. Each piece works in isolation, but they need an orchestrator to tie everything into a single autonomous agent.

In this step, you will build the main agent loop that wires all four modules together. The agent connects to your Impostor server, joins a lobby, listens for game state changes, and responds to meetings by chatting and voting.

In this step, get ready to:
  • Connect the protocol client, state tracker, and AI brain into one agent loop.
  • Run the bot and have it join a lobby with friends.
  • Observe the bot participating in meetings and casting votes.
Connect the protocol client, state tracker, and AI brain into one agent loop

The agent class is the glue between your four modules. It initializes the Hazel client, attaches the protocol parser as a data callback, and runs an async game loop that triggers AI actions when meetings happen.

  • Create a new file called agent.py inside your ~/among-us-bot folder by running:
touch ~/among-us-bot/agent.py
  • Open agent.py in your editor and add the imports, configuration constants, and the class constructor with start() method:
import asyncio
from hazel_client import HazelClient
from game_state import GameState, GamePhase, PlayerRole
from among_us_protocol import ProtocolParser, build_join_game_packet, build_chat_message, build_vote_packet
from ai_brain import generate_meeting_chat, decide_vote

BOT_NAME = "AICrewmate"
SERVER_IP = "127.0.0.1"
SERVER_PORT = 22023

class AmongUsAgent:
    def __init__(self, lobby_code: str):
        self.lobby_code = lobby_code
        self.state = GameState()
        self.parser = ProtocolParser(self.state)
        self.client = HazelClient(SERVER_IP, SERVER_PORT)
        # Wire up event callbacks so the client feeds data into our parser
        self.client.on_data_received = self._on_data
        self.client.on_disconnect = self._on_disconnect
        # Track whether we've acted this meeting to avoid spam
        self.has_chatted_this_meeting = False
        self.has_voted_this_meeting = False

    async def start(self):
        print(f"[Agent] Connecting to {SERVER_IP}:{SERVER_PORT}...")
        await self.client.connect(BOT_NAME)
        print("[Agent] Connected! Joining lobby...")
        # Build and send the join game packet with our lobby code
        join_packet = build_join_game_packet(self.lobby_code)
        await self.client.send_reliable(join_packet)
        print(f"[Agent] Join request sent for lobby: {self.lobby_code}")
        await self._game_loop()

What does this code do?

The imports pull in every module you built in previous steps. BOT_NAME, SERVER_IP, and SERVER_PORT configure where the bot connects and what name it uses in-game.

The constructor wires everything together. ProtocolParser is given the GameState object, and the HazelClient's on_data_received callback points at _on_data so every packet from the server flows through the parser automatically.

The start() method performs the handshake, sends a join request for the provided lobby code, then hands control to the game loop.

  • Below the start() method, add the event callbacks and the main game loop:
    async def _on_data(self, data: bytes):
        old_phase = self.state.phase
        self.parser.parse_message(data)

        # Log phase transitions and reset meeting flags
        if self.state.phase != old_phase:
            print(f"[Agent] Phase changed: {old_phase.name} -> {self.state.phase.name}")
            if self.state.phase == GamePhase.DISCUSSION:
                self.has_chatted_this_meeting = False
                self.has_voted_this_meeting = False
            if self.state.phase == GamePhase.TASKS and old_phase == GamePhase.LOBBY:
                role_name = "IMPOSTOR" if self.state.my_role == PlayerRole.IMPOSTOR else "CREWMATE"
                print(f"[Agent] Game started! I am a {role_name}")

    async def _on_disconnect(self):
        print("[Agent] Disconnected from server!")

    async def _game_loop(self):
        while self.client.connected:
            # When a meeting starts, wait briefly then chat
            if self.state.phase == GamePhase.DISCUSSION and not self.has_chatted_this_meeting:
                await asyncio.sleep(3.0)
                await self._do_meeting_chat()
                self.has_chatted_this_meeting = True

            # After chatting, wait then cast a vote
            if self.state.phase in (GamePhase.DISCUSSION, GamePhase.VOTING) and not self.has_voted_this_meeting:
                await asyncio.sleep(8.0)
                await self._do_vote()
                self.has_voted_this_meeting = True

            await asyncio.sleep(0.5)

What does the game loop do?

_on_data is called every time the server sends a packet. It feeds raw bytes into the parser, then checks if the game phase changed. When a meeting starts, it resets the chat and vote flags so the bot can act again.

_game_loop runs continuously at 0.5-second intervals. It waits 3 seconds before chatting (to seem human) and 8 seconds before voting (to let chat happen first). The flags prevent the bot from spamming multiple messages or votes per meeting.

  • Below the game loop, add the AI meeting methods and the program entry point:
    async def _do_meeting_chat(self):
        context = self._build_context()
        print("[Agent] Generating chat message...")
        message = generate_meeting_chat(context)
        print(f"[Agent] Saying: {message}")
        # Look up our net_id to send chat as ourselves
        my_net_id = 0
        if self.state.my_player_id in self.state.players:
            my_net_id = self.state.players[self.state.my_player_id].net_id
        chat_packet = build_chat_message(my_net_id, message)
        await self.client.send_reliable(chat_packet)

    async def _do_vote(self):
        context = self._build_context()
        print("[Agent] Deciding vote...")
        vote_target = decide_vote(context)
        print(f"[Agent] Voting for: {vote_target}")
        # Resolve player name to their ID, or 0xFF for skip
        target_id = 0xFF
        if vote_target != "skip":
            for pid, player in self.state.players.items():
                if player.name.lower() == vote_target.lower():
                    target_id = pid
                    break
        my_net_id = 0
        if self.state.my_player_id in self.state.players:
            my_net_id = self.state.players[self.state.my_player_id].net_id
        vote_packet = build_vote_packet(my_net_id, self.state.my_player_id, target_id)
        await self.client.send_reliable(vote_packet)

    def _build_context(self) -> dict:
        # Assemble game context for the AI brain
        my_name = BOT_NAME
        if self.state.my_player_id in self.state.players:
            my_name = self.state.players[self.state.my_player_id].name or BOT_NAME
        return {
            "my_name": my_name,
            "my_role": "impostor" if self.state.my_role == PlayerRole.IMPOSTOR else "crewmate",
            "alive_players": self.state.get_alive_player_names(),
            "dead_players": [p.name for p in self.state.get_dead_players()],
            "recent_chat": self.state.chat_messages[-10:],
        }

async def main():
    import sys
    # Accept lobby code as a command-line argument, default to ABCDEF
    lobby_code = sys.argv[1] if len(sys.argv) > 1 else "ABCDEF"
    agent = AmongUsAgent(lobby_code)
    try:
        await agent.start()
    except KeyboardInterrupt:
        print("\n[Agent] Shutting down...")
        await agent.client.disconnect()

if __name__ == "__main__":
    asyncio.run(main())

What do the meeting methods do?

_do_meeting_chat builds context from the current game state and passes it to the Ollama-powered generate_meeting_chat() function. It then sends the generated text as an in-game chat message using the bot's net_id.

_do_vote asks the LLM who to vote for, resolves the chosen name to a player ID, and sends a vote packet. If the LLM returns "skip" or an unrecognized name, the bot skips the vote (0xFF).

_build_context assembles a dictionary with the bot's name, role, alive/dead players, and recent chat. This is the "world view" the LLM uses to reason about the game.

  • Save agent.py.
  • Confirm the file exists by running:
ls ~/among-us-bot/agent.py

You should see agent.py listed in the output.

File not showing up?

  • Make sure you saved the file in the ~/among-us-bot/ directory, not somewhere else.
  • Double-check that your editor saved successfully (look for unsaved indicators in the tab).

Help me find where agent.py was saved

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py and that it contains all the code from the three blocks above.

ⓧ I'd like to double check the full code

import asyncio
from hazel_client import HazelClient
from game_state import GameState, GamePhase, PlayerRole
from among_us_protocol import ProtocolParser, build_join_game_packet, build_chat_message, build_vote_packet
from ai_brain import generate_meeting_chat, decide_vote

BOT_NAME = "AICrewmate"
SERVER_IP = "127.0.0.1"
SERVER_PORT = 22023

class AmongUsAgent:
    def __init__(self, lobby_code: str):
        self.lobby_code = lobby_code
        self.state = GameState()
        self.parser = ProtocolParser(self.state)
        self.client = HazelClient(SERVER_IP, SERVER_PORT)
        self.client.on_data_received = self._on_data
        self.client.on_disconnect = self._on_disconnect
        self.has_chatted_this_meeting = False
        self.has_voted_this_meeting = False

    async def start(self):
        print(f"[Agent] Connecting to {SERVER_IP}:{SERVER_PORT}...")
        await self.client.connect(BOT_NAME)
        print("[Agent] Connected! Joining lobby...")
        join_packet = build_join_game_packet(self.lobby_code)
        await self.client.send_reliable(join_packet)
        print(f"[Agent] Join request sent for lobby: {self.lobby_code}")
        await self._game_loop()

    async def _on_data(self, data: bytes):
        old_phase = self.state.phase
        self.parser.parse_message(data)

        if self.state.phase != old_phase:
            print(f"[Agent] Phase changed: {old_phase.name} -> {self.state.phase.name}")
            if self.state.phase == GamePhase.DISCUSSION:
                self.has_chatted_this_meeting = False
                self.has_voted_this_meeting = False
            if self.state.phase == GamePhase.TASKS and old_phase == GamePhase.LOBBY:
                role_name = "IMPOSTOR" if self.state.my_role == PlayerRole.IMPOSTOR else "CREWMATE"
                print(f"[Agent] Game started! I am a {role_name}")

    async def _on_disconnect(self):
        print("[Agent] Disconnected from server!")

    async def _game_loop(self):
        while self.client.connected:
            if self.state.phase == GamePhase.DISCUSSION and not self.has_chatted_this_meeting:
                await asyncio.sleep(3.0)
                await self._do_meeting_chat()
                self.has_chatted_this_meeting = True

            if self.state.phase in (GamePhase.DISCUSSION, GamePhase.VOTING) and not self.has_voted_this_meeting:
                await asyncio.sleep(8.0)
                await self._do_vote()
                self.has_voted_this_meeting = True

            await asyncio.sleep(0.5)

    async def _do_meeting_chat(self):
        context = self._build_context()
        print("[Agent] Generating chat message...")
        message = generate_meeting_chat(context)
        print(f"[Agent] Saying: {message}")
        my_net_id = 0
        if self.state.my_player_id in self.state.players:
            my_net_id = self.state.players[self.state.my_player_id].net_id
        chat_packet = build_chat_message(my_net_id, message)
        await self.client.send_reliable(chat_packet)

    async def _do_vote(self):
        context = self._build_context()
        print("[Agent] Deciding vote..."
        )
        vote_target = decide_vote(context)
        print(f"[Agent] Voting for: {vote_target}")
        target_id = 0xFF
        if vote_target != "skip":
            for pid, player in self.state.players.items():
                if player.name.lower() == vote_target.lower():
                    target_id = pid
                    break
        my_net_id = 0
        if self.state.my_player_id in self.state.players:
            my_net_id = self.state.players[self.state.my_player_id].net_id
        vote_packet = build_vote_packet(my_net_id, self.state.my_player_id, target_id)
        await self.client.send_reliable(vote_packet)

    def _build_context(self) -> dict:
        my_name = BOT_NAME
        if self.state.my_player_id in self.state.players:
            my_name = self.state.players[self.state.my_player_id].name or BOT_NAME
        return {
            "my_name": my_name,
            "my_role": "impostor" if self.state.my_role == PlayerRole.IMPOSTOR else "crewmate",
            "alive_players": self.state.get_alive_player_names(),
            "dead_players": [p.name for p in self.state.get_dead_players()],
            "recent_chat": self.state.chat_messages[-10:],
        }

async def main():
    import sys
    lobby_code = sys.argv[1] if len(sys.argv) > 1 else "ABCDEF"
    agent = AmongUsAgent(lobby_code)
    try:
        await agent.start()
    except KeyboardInterrupt:
        print("\n[Agent] Shutting down...")
        await agent.client.disconnect()

if __name__ == "__main__":
    asyncio.run(main())
Run the bot and have friends connect their Among Us clients

The bot needs a lobby to join. One of your friends creates a game on your Impostor server, then shares the 6-character lobby code with you so the bot can connect.

How do friends connect to your server?

Each friend needs to point their Among Us client at your Impostor server. They visit https://impostor.github.io/Impostor/ and follow the instructions for their platform to configure a region file with your server's IP address and port 22023.

If friends are on the same local network, use your machine's LAN IP as the PublicIp in your Impostor config.json. For friends connecting over the internet, you need to forward UDP port 22023 on your router and set PublicIp to your public IP.

  • Have a friend open Among Us, connect to your server, and create a game lobby.
  • Ask your friend for the 6-character lobby code (shown in the bottom of the lobby screen).
  • Switch back to your terminal (with the virtual environment still active) and run the bot with the lobby code:
cd ~/among-us-bot
python agent.py [[LOBBY_CODE="your 6-character lobby code e.g. XYZABC"]]

You should see output confirming the connection:

[Agent] Connecting to 127.0.0.1:22023...
[Agent] Connected! Joining lobby...
[Agent] Join request sent for lobby: XYZABC
  • Check your friend's game lobby. They should see a new player named AICrewmate appear in the lobby.

Bot not connecting?

  • Make sure your Impostor server is still running. Check that the process is active in its terminal window.
  • Verify the lobby code is correct. Codes are 6 uppercase letters (e.g. XYZABC).
  • If you see a disconnect immediately after connecting, the game version encoding may not match. Check the Impostor server terminal for error messages about version mismatches.

Help me debug why my bot won't connect to the Impostor server

Observe the bot participating in meetings and casting votes

With the bot in the lobby, it is time to play a real game and watch it in action.

  • Have the lobby host start the game.
  • Watch your terminal. When the game starts, you should see a role announcement:
[Agent] Game started! I am a CREWMATE
  • Have a friend call an emergency meeting or report a body.
  • Watch your terminal for the AI generating a response:
[Agent] Phase changed: TASKS -> DISCUSSION
[Agent] Generating chat message...
[Agent] Saying: where was everyone during lights?
[Agent] Deciding vote...
[Agent] Voting for: PlayerName

Your friends should see the bot's chat message appear in-game and its vote registered during the voting phase.

What should I watch for?

  • The bot waits 3 seconds before chatting, mimicking a human thinking about what to say.
  • It waits 8 seconds before voting, giving time for discussion to develop.
  • The LLM generates different messages each time based on who is alive, who is dead, and what others have said.
  • If the bot is assigned Impostor, it deflects suspicion instead of accusing others directly.

Stop the bot gracefully

Press Ctrl+C in the terminal running the bot. The KeyboardInterrupt handler sends a disconnect packet to the server so the bot leaves the lobby cleanly.

Your AI agent is alive. It joins lobbies, reads the game state in real time, chats during meetings, and casts strategic votes. Next up, you can give the bot a devious upgrade in the Secret Mission.

Secret mission

Impostor Mode

Your bot plays a decent crewmate, but what happens when it gets assigned Impostor? Right now it uses a basic deflection prompt. Give the bot a true Impostor brain that tracks its kills, crafts alibis, identifies who suspects it, and votes strategically to eliminate accusers.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. The Impostor server and Ollama both operate locally and cost nothing to keep running.

Decide whether to keep your resources running for future game nights, pause them to free up memory, or delete them entirely.

Resources you used:

  • Impostor server files at ~/impostor/.
  • Python project files at ~/among-us-bot/.
  • Ollama with the gemma2:2b model pulled.
  • .NET 8 ASP.NET Core Runtime (installed via snap).
  • UDP port 22023 (open if you forwarded it for friends to connect).

Keep everything running

No action needed. Choose this if you plan to host more game nights and want the server ready to go.

  • The Impostor server only uses resources when actively running in a terminal. If you closed the terminal, it is already stopped.
  • Ollama runs as a background service but unloads models from VRAM after 5 minutes of inactivity. No GPU memory is consumed while idle.
  • Your Python virtual environment at ~/among-us-bot/venv/ takes minimal disk space.
  • If you forwarded port 22023 on your router for friends to connect, consider closing that port when you are not hosting games.

Pause - I'll come back to this later

Stop running processes to free up memory and GPU resources. Your files and installed tools remain intact for next time.

  • If the Impostor server is still running in a terminal, press Ctrl+C in that terminal to stop it.
  • Unload the gemma2:2b model from GPU memory by running:
ollama stop gemma2:2b
  • Stop the Ollama background service by running:
sudo systemctl stop ollama
  • Deactivate your Python virtual environment by running:
deactivate
  • If you forwarded port 22023 on your router, close it in your router settings.

Delete - I don't want to use this again

Remove all project files, models, and services from your machine. This frees up disk space but you will need to set everything up again if you change your mind.

  • Stop the Impostor server if it is still running (Ctrl+C in the terminal).
  • Remove the Impostor server files by running:
rm -rf ~/impostor/
  • Remove the Python project folder (including the virtual environment) by running:
rm -rf ~/among-us-bot/
  • Remove the gemma2:2b model from Ollama by running:
ollama rm gemma2:2b
  • Stop and fully uninstall Ollama by running these commands:
sudo systemctl stop ollama
sudo systemctl disable ollama
sudo rm /etc/systemd/system/ollama.service
sudo rm $(which ollama)
sudo rm -rf /usr/share/ollama
sudo userdel ollama
sudo groupdel ollama
  • Remove the .NET 8 runtime by running:
sudo snap remove dotnet-sdk
  • If you forwarded port 22023 on your router, close it in your router settings.

Nice Work!

Nice Work!

You just built a genuine AI game agent that plays Among Us with real humans. Your bot connects to a self-hosted server, reads the game state in real time, and uses a local LLM to chat and vote during meetings.

You've learned how to:

  • Self-host a private Among Us server on Linux using the Impostor server and .NET 8 runtime.
  • Implement the Hazel UDP protocol from scratch in Python, including packet parsing, reliable delivery, and connection handshakes.
  • Build an autonomous agent that tracks game state in real time and uses a local LLM to generate social deduction chat and cast strategic votes.
  • Give the bot an Impostor brain that lies, crafts alibis around its kills, counter-accuses suspicious players, and votes strategically to protect itself.

Ready to quiz yourself?