Skip to content

Commit 498b437

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 4bbae85 + a02f690 commit 498b437

21 files changed

+582
-199
lines changed

README.md

+36-17
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# LivePaint
22

3-
This is an example project demonstrating how to build a realtime data app using LiveKit.
3+
This LiveKit example project is a realtime drawing game where players compete to complete a drawing prompt as fast as possible, while being judged by a realtime AI agent that oversees the whole game.
44

5-
In this example, we build a realtime drawing game where players compete to complete a drawing prompt as fast as possible, while being judged by an AI agent that oversees the whole game.
5+
It demonstrates the use of LiveKit's [realtime data messages](https://docs.livekit.io/home/client/data/messages), [room metadata](https://docs.livekit.io/home/client/data/room-metadata/), [RPC](https://docs.livekit.io/home/client/data/rpc/), [participant management](https://docs.livekit.io/home/server/managing-participants/), [token generation](https://docs.livekit.io/home/server/generating-tokens/), and [realtime audio chat](https://docs.livekit.io/home/client/tracks/) in a real-world app built on the LiveKit [JS SDK](https://github.com/livekit/client-sdk-js), [React Components](https://github.com/livekit/components-js), [Python agents SDK](https://github.com/livekit/agents), and [Python Server API](https://github.com/livekit/python-sdks).
66

7-
This example demonstrates the use of [realtime data messages](https://docs.livekit.io/home/client/data/messages), [room metadata](https://docs.livekit.io/home/client/data/room-metadata/), [RPC](https://docs.livekit.io/home/client/data/rpc/), [participant management](https://docs.livekit.io/home/server/managing-participants/), [token generation](https://docs.livekit.io/home/server/generating-tokens/), and [realtime audio chat](https://docs.livekit.io/home/client/tracks/) in a real-world app built on the LiveKit [JS SDK](https://github.com/livekit/client-sdk-js), [React Components](https://github.com/livekit/components-js), [Python agents SDK](https://github.com/livekit/agents), and [Python Server API](https://github.com/livekit/python-sdks).
8-
9-
Try it live at [https://live-paint.vercel.app](https://live-paint.vercel.app)!
7+
Play live at [https://paint.livekit.io](https://paint.livekit.io)!
108

119
## Architecture
1210

11+
This is a short overview of how this game was built. The entire codebase is also annotated with comments that go into more detail. The `agent` directory contains the code for the realtime agent (built on [LiveKit Agents](https://docs.livekit.io/agents)). The `web` directory contains the code for the game frontend (built on [Next.js](https://nextjs.org/) with [LiveKit React Components](https://github.com/livekit/components-js)).
12+
1313
### Rooms & Participants
1414

1515
Each game is hosted in a single [LiveKit room](https://docs.livekit.io/home/client/connect) where each player is a standard participant. The room is reused between games, so the same group of players can complete multiple games back-to-back.
@@ -58,37 +58,56 @@ The agent is responsible for judging each player's drawing. It runs a single loo
5858

5959
Realtime chat is enabled within each room by [publishing the local microphone](https://docs.livekit.io/home/client/tracks/publish/) and [rendering the room audio](https://docs.livekit.io/reference/components/react/component/roomaudiorenderer/).
6060

61-
## Ideas / What's Next?
61+
## Ideas & What's Next?
6262

63-
If you'd like to learn to build with LiveKit, try to implement the following feature ideas or invent your own:
63+
Learn to build with LiveKit by adding one of the following features, or come up with your own!
6464

6565
- Add a scoreboard that shows how many wins each player has racked up
6666
- We think [participant attributes](https://docs.livekit.io/home/client/data/participant-attributes/) is a great place to keep track of this
6767
- Have the AI agent make its guesses and announce winners with realtime audio as well as text
68-
- We'd try using a [Text-To-Speech plugin](https://docs.livekit.io/agents/plugins/#text-to-speech-tts)
69-
- Consider having the agent publish a different track to each participant, so they don't need to hear the guesses for everyone else in realtime
70-
- Add a room list on the front page that shows open rooms and lets you join one
71-
- Try the [List Rooms](https://docs.livekit.io/home/server/managing-rooms/#list-rooms) Server API
68+
- We'd try using a LiveKit [Text-To-Speech (TTS) plugin](https://docs.livekit.io/agents/plugins/#text-to-speech-tts)
69+
- To make it perfect, have the agent [publish](https://docs.livekit.io/home/client/tracks/publish/) a different audio track to each participant so they can hear the guesses for everyone else in realtime
70+
- Add a room list on the front page that shows open rooms and lets you join any of them
71+
- We'd use the [List Rooms](https://docs.livekit.io/home/server/managing-rooms/#list-rooms) Server API in a Next.js API route
7272
- Add support for multiple brush sizes and colors
73-
- You'll probably want to extend the data format for `Line` to record brush size and color
74-
73+
- You'll need to extend the data format for `Line` to record brush size and color
7574

7675
## Development & Running Locally
7776

78-
Run the agent:
77+
You'll need a LiveKit instance to run this project, either from [LiveKit Cloud](https://cloud.livekit.io) or [Self-hosted](https://docs.livekit.io/home/self-hosting/local/).
7978

80-
```
79+
### Running the Agent
80+
81+
First add `agent/.env` with LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL, and OPENAI_API_KEY.
82+
83+
Then run the following commands to install dependencies:
84+
85+
```shell
8186
cd agent
8287
python -m venv venv
8388
source venv/bin/activate
8489
pip install -r requirements.txt
90+
``
91+
92+
Finally, boot the agent:
93+
94+
```shell
8595
python main.py dev
8696
```
8797

88-
Run the site:
98+
### Running the Site
8999

90-
```
100+
First add `web/.env.local` with LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and LIVEKIT_URL.
101+
102+
Then run the following commands to install dependencies:
103+
104+
```shell
91105
cd web
92106
pnpm install
107+
```
108+
109+
Finally, start the site:
110+
111+
```shell
93112
pnpm dev
94113
```

agent/drawings.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from PIL import Image, ImageDraw
55

66

7+
# Points are stored as floats between 0 and 1 and assume a square canvas
8+
# This allows the actual display canvas size to differ between players and the host to fit their needs
79
class Point:
810
def __init__(self, x: float, y: float):
911
self.x = x
@@ -15,6 +17,11 @@ def __init__(self, from_point: Point, to_point: Point):
1517
self.from_point = from_point
1618
self.to_point = to_point
1719

20+
# Lines are encoded as efficiently as possible as they are sent over the network in high frequency as data messages
21+
# and also encoded in bulk as base64 for drawing restoration (the `player.get_drawing` RPC call)
22+
# While points are normally stored as 32-bit floats, we can can save 50% of the space by using 16-bit integers instead when they are sent over the network
23+
# The integers are thus in the range of 0 to 65535. This is more than enough for our purposes, as no player is likely to have a canvas larger than about 1024x1024 pixels anyways
24+
# Also note that we have a parallel implementation in the client in `web/lib/drawings.ts` that performs the same operations in TypeScript
1825
def encode(self) -> bytes:
1926
return struct.pack(
2027
"<HHHH",
@@ -24,6 +31,7 @@ def encode(self) -> bytes:
2431
int(self.to_point.y * 65535),
2532
)
2633

34+
# We decode lines by reversing the packing operation performed above
2735
@staticmethod
2836
def decode(data: bytes) -> "Line":
2937
return Line(
@@ -44,6 +52,8 @@ def __init__(self, player_identity: str):
4452
self.lines = set()
4553
self._hash = None
4654

55+
# We use an MD5 hash of the line segments to identify duplicate drawings
56+
# This allows us to make efficient keys for the `GuessCache` that will automatically change when the drawing is modified
4757
def hash(self) -> str:
4858
if self._hash:
4959
return self._hash
@@ -55,14 +65,19 @@ def hash(self) -> str:
5565
self._hash = hash_obj.hexdigest()
5666
return self._hash
5767

68+
# Adds a new line to the drawing
5869
def add_line(self, line: Line):
5970
self.lines.add(line)
6071
self._hash = None
6172

73+
# Clears the drawing (removes all lines)
6274
def clear(self):
6375
self.lines.clear()
6476
self._hash = None
6577

78+
# Generates an image representing the current state of the drawing
79+
# We use a size of 512x512 by default, which is an efficient size for GPT-4o-mini in "low detail" mode
80+
# See https://platform.openai.com/docs/guides/vision#low-or-high-fidelity-image-understanding for more information
6681
def get_image(self, size: int = 512, stroke_width: int = 4) -> Image:
6782
canvas = Image.new("1", (size, size), 1)
6883
draw = ImageDraw.Draw(canvas)
@@ -82,6 +97,4 @@ def get_image(self, size: int = 512, stroke_width: int = 4) -> Image:
8297
width=stroke_width,
8398
)
8499

85-
debug_path = f"/tmp/drawing_{self.player_identity}.png"
86-
canvas.save(debug_path)
87100
return canvas

agent/game.py

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from typing import Literal, List
2+
import json
3+
from collections import OrderedDict
4+
5+
DifficultyLevel = Literal["easy", "medium", "hard"]
6+
7+
PROMPTS = {
8+
"easy": [
9+
"cat",
10+
"dog",
11+
"elephant",
12+
"giraffe",
13+
"lion",
14+
"monkey",
15+
"penguin",
16+
"rabbit",
17+
"turtle",
18+
"bed",
19+
"door",
20+
"fan",
21+
"apple",
22+
"banana",
23+
"cake",
24+
"cookie",
25+
"car",
26+
"boat",
27+
"bus",
28+
],
29+
"medium": [
30+
"airplane",
31+
"helicopter",
32+
"rocket",
33+
"castle",
34+
"bridge",
35+
"lighthouse",
36+
"windmill",
37+
"doctor",
38+
"chef",
39+
"pilot",
40+
"dancer",
41+
"baseball",
42+
"basketball",
43+
"soccer",
44+
"tennis",
45+
"robot",
46+
"dragon",
47+
"wizard",
48+
"pirate",
49+
"ghost",
50+
],
51+
"hard": [
52+
"thunderstorm",
53+
"northern lights",
54+
"coral reef",
55+
"redwood forest",
56+
"hot air balloon",
57+
"vacuum cleaner",
58+
"musical conductor",
59+
"construction site",
60+
"garden party",
61+
"tug of war",
62+
"arm wrestling",
63+
"rock climbing",
64+
"thumb wrestling",
65+
"playing chess",
66+
"building sandcastle",
67+
],
68+
}
69+
70+
NO_GUESS = "NO_GUESS"
71+
CHEATER_CHEATER = "CHEATER_CHEATER"
72+
PARTICIPANT_LIMIT = 12
73+
74+
75+
class GameState:
76+
def __init__(
77+
self,
78+
started: bool = False,
79+
difficulty: DifficultyLevel = "easy",
80+
prompt: str | None = None,
81+
winners: List[str] = [],
82+
):
83+
self.started = started
84+
self.difficulty = difficulty
85+
self.prompt = prompt
86+
self.winners = winners
87+
88+
def to_json_string(self) -> str:
89+
return json.dumps(self.__dict__)
90+
91+
@staticmethod
92+
def from_json_string(json_string: str) -> "GameState":
93+
return GameState(**json.loads(json_string))
94+
95+
96+
class GuessCache:
97+
def __init__(self, max_size: int = 1000):
98+
self._cache = OrderedDict()
99+
self._max_size = max_size
100+
101+
def get(self, hash: str) -> str | None:
102+
if hash in self._cache:
103+
self._cache.move_to_end(hash)
104+
return self._cache[hash]
105+
return None
106+
107+
def set(self, hash: str, guess: str):
108+
if hash in self._cache:
109+
self._cache.move_to_end(hash)
110+
else:
111+
if len(self._cache) >= self._max_size:
112+
self._cache.popitem(last=False)
113+
self._cache[hash] = guess

0 commit comments

Comments
 (0)