Showing preview only (1,105K chars total). Download the full file or copy to clipboard to get everything.
Repository: a16z-infra/ai-town
Branch: main
Commit: 2693ed6973e3
Files: 159
Total size: 1.0 MB
Directory structure:
gitextract_ov7oqzfx/
├── .dockerignore
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── .vercelignore
├── .vscode/
│ ├── convex.code-snippets
│ └── settings.json
├── ARCHITECTURE.md
├── Dockerfile
├── LICENSE
├── README.md
├── convex/
│ ├── _generated/
│ │ ├── api.d.ts
│ │ ├── api.js
│ │ ├── dataModel.d.ts
│ │ ├── server.d.ts
│ │ └── server.js
│ ├── agent/
│ │ ├── conversation.ts
│ │ ├── embeddingsCache.ts
│ │ ├── memory.ts
│ │ └── schema.ts
│ ├── aiTown/
│ │ ├── agent.ts
│ │ ├── agentDescription.ts
│ │ ├── agentInputs.ts
│ │ ├── agentOperations.ts
│ │ ├── conversation.ts
│ │ ├── conversationMembership.ts
│ │ ├── game.ts
│ │ ├── ids.ts
│ │ ├── inputHandler.ts
│ │ ├── inputs.ts
│ │ ├── insertInput.ts
│ │ ├── location.ts
│ │ ├── main.ts
│ │ ├── movement.ts
│ │ ├── player.ts
│ │ ├── playerDescription.ts
│ │ ├── schema.ts
│ │ ├── world.ts
│ │ └── worldMap.ts
│ ├── constants.ts
│ ├── crons.ts
│ ├── engine/
│ │ ├── abstractGame.ts
│ │ ├── historicalObject.test.ts
│ │ ├── historicalObject.ts
│ │ └── schema.ts
│ ├── http.ts
│ ├── init.ts
│ ├── messages.ts
│ ├── music.ts
│ ├── schema.ts
│ ├── testing.ts
│ ├── util/
│ │ ├── FastIntegerCompression.ts
│ │ ├── assertNever.ts
│ │ ├── asyncMap.test.ts
│ │ ├── asyncMap.ts
│ │ ├── compression.test.ts
│ │ ├── compression.ts
│ │ ├── geometry.test.ts
│ │ ├── geometry.ts
│ │ ├── isSimpleObject.ts
│ │ ├── llm.ts
│ │ ├── minheap.test.ts
│ │ ├── minheap.ts
│ │ ├── object.ts
│ │ ├── sleep.ts
│ │ ├── types.test.ts
│ │ ├── types.ts
│ │ └── xxhash.ts
│ └── world.ts
├── data/
│ ├── animations/
│ │ ├── campfire.json
│ │ ├── gentlesparkle.json
│ │ ├── gentlesplash.json
│ │ ├── gentlewaterfall.json
│ │ └── windmill.json
│ ├── characters.ts
│ ├── convertMap.js
│ ├── gentle.js
│ └── spritesheets/
│ ├── f1.ts
│ ├── f2.ts
│ ├── f3.ts
│ ├── f4.ts
│ ├── f5.ts
│ ├── f6.ts
│ ├── f7.ts
│ ├── f8.ts
│ ├── p1.ts
│ ├── p2.ts
│ ├── p3.ts
│ ├── player.ts
│ └── types.ts
├── docker-compose.yml
├── fly/
│ ├── README.md
│ ├── backend/
│ │ └── fly.toml
│ └── dashboard/
│ └── fly.toml
├── index.html
├── jest.config.ts
├── package.json
├── postcss.config.js
├── public/
│ └── assets/
│ └── tilemap.json
├── src/
│ ├── App.tsx
│ ├── components/
│ │ ├── Character.tsx
│ │ ├── ConvexClientProvider.tsx
│ │ ├── DebugPath.tsx
│ │ ├── DebugTimeManager.tsx
│ │ ├── FreezeButton.tsx
│ │ ├── Game.tsx
│ │ ├── MessageInput.tsx
│ │ ├── Messages.tsx
│ │ ├── PixiGame.tsx
│ │ ├── PixiStaticMap.tsx
│ │ ├── PixiViewport.tsx
│ │ ├── Player.tsx
│ │ ├── PlayerDetails.tsx
│ │ ├── PositionIndicator.tsx
│ │ ├── PoweredByConvex.tsx
│ │ └── buttons/
│ │ ├── Button.tsx
│ │ ├── InteractButton.tsx
│ │ ├── LoginButton.tsx
│ │ └── MusicButton.tsx
│ ├── editor/
│ │ ├── README.md
│ │ ├── campfire.json
│ │ ├── eutils.js
│ │ ├── gentlesparkle.json
│ │ ├── gentlesplash.json
│ │ ├── gentlewaterfall.json
│ │ ├── index.html
│ │ ├── le.html
│ │ ├── le.js
│ │ ├── leconfig.js
│ │ ├── lecontext.js
│ │ ├── lehtmlui.js
│ │ ├── mapfile.js
│ │ ├── maps/
│ │ │ ├── gentle-full.js
│ │ │ ├── gentle.js
│ │ │ ├── gentleanim.js
│ │ │ ├── mage3.js
│ │ │ └── serene.js
│ │ ├── se.html
│ │ ├── se.js
│ │ ├── seconfig.js
│ │ ├── secontext.js
│ │ ├── sehtmlui.js
│ │ ├── spritefile.js
│ │ ├── undo.js
│ │ └── windmill.json
│ ├── hooks/
│ │ ├── sendInput.ts
│ │ ├── serverGame.ts
│ │ ├── useHistoricalTime.ts
│ │ ├── useHistoricalValue.ts
│ │ └── useWorldHeartbeat.ts
│ ├── index.css
│ ├── main.tsx
│ ├── toasts.ts
│ └── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── vercel.json
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# flyctl launch added from .gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
**/game/node_modules
.pnp
**/.pnp.js
# testing
coverage
# next.js
.next
out
# production
build
# misc
**/.DS_Store
**/*.pem
# debug
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
# local env files
**/.env*.local
# vercel
**/.vercel
# typescript
**/*.tsbuildinfo
**/next-env.d.ts
**/.env
.env.prod
fly.toml
# Vite build
**/dist
**/convex-local*
**/convex_local*
================================================
FILE: .eslintignore
================================================
webpack*
.eslintrc.js
next.config.js
tailwind.config.js
postcss.config.js
convex/_generated/*
dist/*
================================================
FILE: .eslintrc.js
================================================
export default {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
plugins: ['@typescript-eslint'],
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
'plugin:@typescript-eslint/recommended-type-checked',
],
parserOptions: {
project: './tsconfig.json',
ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-non-null-assertion': 'off',
},
};
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
game/node_modules/
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
/.env.prod
# Vite build
dist
convex-local*
convex_local*
================================================
FILE: .prettierrc
================================================
{
"trailingComma": "all",
"singleQuote": true,
"bracketSpacing": true,
"tabWidth": 2,
"proseWrap": "always",
"printWidth": 100
}
================================================
FILE: .vercelignore
================================================
convex-local*
convex_local*
================================================
FILE: .vscode/convex.code-snippets
================================================
{
"Convex Imports": {
"prefix": "convex:imports",
"body": [
"import { v } from \"convex/values\";",
"import { api, internal } from \"./_generated/api\";",
"import { Doc, Id } from \"./_generated/dataModel\";",
"import {",
" action,",
" internalAction,",
" internalMutation,",
" internalQuery,",
" mutation,",
" query,",
"} from \"./_generated/server\";"
],
"scope": "javascript,typescript",
"isFileTemplate": true
},
"Convex Query": {
"prefix": "convex:query",
"body": [
"export const $1 = query({",
" args: {},",
" handler: async (ctx, args) => {",
" $0",
" },",
"});"
],
"scope": "javascript,typescript"
},
"Convex Internal Query": {
"prefix": "convex:internalQuery",
"body": [
"export const $1 = internalQuery({",
" args: {},",
" handler: async (ctx, args) => {",
" $0",
" },",
"});"
],
"scope": "javascript,typescript"
},
"Convex Mutation": {
"prefix": "convex:mutation",
"body": [
"export const $1 = mutation({",
" args: {},",
" handler: async (ctx, args) => {",
" $0",
" },",
"});"
],
"scope": "javascript,typescript"
},
"Convex Internal Mutation": {
"prefix": "convex:internalMutation",
"body": [
"export const $1 = internalMutation({",
" args: {},",
" handler: async (ctx, args) => {",
" $0",
" },",
"});"
],
"scope": "javascript,typescript"
},
"Convex Action": {
"prefix": "convex:action",
"body": [
"import { action } from \"./_generated/server\";",
"",
"export const $1 = action({",
" args: {},",
" handler: async (ctx, args) => {",
" $0",
" },",
"});"
],
"scope": "javascript,typescript"
},
"Convex Internal Action": {
"prefix": "convex:internalAction",
"body": [
"import { internalAction } from \"./_generated/server\";",
"",
"export const $1 = internalAction({",
" args: {},",
" handler: async (ctx, args) => {",
" $0",
" },",
"});"
],
"scope": "javascript,typescript"
}
}
================================================
FILE: .vscode/settings.json
================================================
{
"editor.formatOnSave": true,
"editor.tabSize": 2,
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.preferences.importModuleSpecifierEnding": "auto",
"javascript.preferences.importModuleSpecifierEnding": "auto"
}
================================================
FILE: ARCHITECTURE.md
================================================
# Architecture
This documents dives into the high-level architecture of AI Town and its different layers. We'll
first start with a brief overview and then go in-depth on each component. The overview should
be sufficient for forking AI Town and changing game or agent behavior. Read on to the deep dives
if you're interested or running up against the engine's limitations.
This doc assumes the reader has a working knowledge of Convex. If you're new to Convex, check out
the [Convex tutorial](https://docs.convex.dev/get-started) to get started.
## Overview
AI Town is split into a few layers:
- The server-side game logic in `convex/aiTown`: This layer defines what state AI Town maintains,
how it evolves over time, and how it reacts to user input. Both humans and agents submit inputs
that the game engine processes.
- The client-side game UI in `src/`: AI Town uses `pixi-react` to render the game state to the
browser for human consumption.
- The game engine in `convex/engine`: To make it easy to hack on the game rules, we've separated
out the game engine from the AI Town-specific game rules. The game engine is responsible for
saving and loading game state from the database, coordinating feeding inputs into the engine,
and actually running the game engine in Convex functions.
- The agent in `convex/agent`: Agents run as part of the game loop, and can kick off asynchronous
Convex functions to do longer processing, such as talking to LLMs. Those functions can save state
in separate tables, or submit inputs to the game engine to modify game state. Internally, our
agents use a combination of simple rule-based systems and talking to an LLM.
So, if you'd like to tweak agent behavior but keep the same game mechanics, check out `convex/agent`
for the async work, and `convex/aiTown/agent.ts` for the game loop logic.
If you would like to add new gameplay elements (that both humans and agents can interact with), add
the feature to `convex/aiTown`, render it in the UI in `src/`, and respond to it in `convex/aiTown/agent.ts`.
If you have parts of your game that are more latency sensitive, you can move them out of engine
into regular Convex tables, queries, and mutations, only logging key bits into game state. See
"Message data model" below for an example.
## AI Town game logic (`convex/aiTown`)
### Data model
AI Town's data model has a few concepts:
- Worlds (`convex/aiTown/world.ts`) represent a map with many players interacting together.
- Players (`convex/aiTown/player.ts`) are the core characters in the game. Players have human readable names and
descriptions, and they may be associated with a human user. At any point in time, a player may be pathfinding
towards some destination and has a current location.
- Conversations (`convex/aiTown/conversations.ts`) are created by a player and end at some point in time.
- Conversation memberships (`convex/aiTown/conversationMembership.ts`) indicate that a player is a member
of a conversation. Players may only be in one conversation at any point in time, and conversations
currently have exactly two members. Memberships may be in one of three states:
- `invited`: The player has been invited to the conversation but hasn't accepted yet.
- `walkingOver`: The player has accepted the invite to the conversation but is too far away to talk. The
player will automatically join the conversation when they get close enough.
- `participating`: The player is actively participating in the conversation.
### Schema
There are three main categories of tables:
1. Engine tables (`convex/engine/schema.ts`) for maintaining engine-internal state.
2. Game tables (`convex/aiTown/schema.ts`) for game state. To keep game state small and efficient to
read and write, we store AI Town's data model across a few tables. See `convex/aiTown/schema.ts` for an overview.
3. Agent tables (`convex/agent/schema.ts`) for agent state. Agents can freely read and write to these tables
within their actions.
### Inputs (`convex/aiTown/inputs.ts`)
AI Town modifies its data model by processing inputs. Inputs are submitted by players and agents and
processed by the game engine. We specify inputs in the `inputs` object in `convex/aiTown/inputs.ts`.
Use the `inputHandler` function to construct an input handler, specifying a Convex validator for
arguments for end-to-end type-safety.
- Joining (`join`) and leaving (`leave`) the game.
- Moving a player to a particular location (`moveTo`): Movement in AI Town is similar to RTS games, where
the players specify where they want to go, and the engine figures out how to get there.
- Starting a conversation (`startConversation`), accepting an invite (`acceptInvite`), rejecting an invite
(`rejectInvite`), and leaving a conversation (`leaveConversation`). To track typing indicators,
you use `startTyping` and `finishSendingMessage`. These are imported from `game/conversations.ts`.
- Agent inputs are imported from `aiTown/agentInputs.ts` for things like remembering conversations,
deciding what to do, etc.
Each of these inputs' implementation method checks invariants and updates game state as desired.
For example, the `moveTo` input checks that the player isn't participating in a conversation,
throwing an error telling them to leave the conversation first if so, and then updates their
pathfinding state with the desired destination.
### Simulation
Other than when processing player inputs, the game state can change over time in the background as the
simulation runs time forward. For example, if a player has decided to move along a path, their position
will gradually update as time moves forward. Similarly, if two players collide into each other, they'll
notice and replan their paths, trying to avoid obstacles.
### Message data model
We manage the tables for tracking chat messages in separate tables not affiliated
with the game engine. This is for a few reasons:
- The core simulation doesn't need to know about messages, so keeping them
out keeps game state small.
- Messages are updated very frequently (when streamed out from OpenAI) and
benefit from lower input latency, so they're not a great fit for the engine.
See "Design goals and limitations" below.
Messages (`convex/schema.ts`) are in a conversation and indicate an author and message text.
Each conversation has a typing state in the conversations table that indicates that a player
is currently typing. Players can still send messages while another player is typing, but
having the indicator helps agents (and humans) not talk over each other.
The separate tables are queried and modified with regular Convex queries and mutations
that don't directly go through the simulation.
## Game engine (`convex/engine`)
Given the description of AI Town's game behavior in the previous section,
the `AbstractGame` class in `convex/engine/abstractGame.ts` implements actually running the simulation.
The game engine has a few responsibilities:
- Coordinating incoming player inputs, feeding them into the simulation, and sending their
return values (or errors) to the client.
- Running the simulation forward in time.
- Saving and loading game state from the database.
- Managing executing the game behavior, efficiently using Convex resources and minimizing input latency.
AI Town's game behavior is implemented in the `Game` subclass.
### Input handling
Users submit inputs through the `insertInput` function, which inserts them into an `inputs` table, assigning a
monotonically increasing unique input number and stamping the input with the time the server received it. The
engine then processes inputs, writing their results back to the `inputs` row. Interested clients can subscribe
on an input's status with the `inputStatus` query.
`Game` provides an abstract method `handleInput` that `AiTown` implements with its specific behavior.
### Running the simulation
The `Game` class specifies how it simulates time forward with the `tick` method:
- `tick(now)` runs the simulation forward until the given timestamp
- Ticks are run at a high frequency, configurable with `tickDuration` (milliseconds). Since AI town has smooth motion
for player movement, it runs at 60 ticks per second.
- It's generally a good idea to break up game logic into separate systems that can be ticked forward independently.
For example, AI Town's `tick` method advances pathfinding with `Player.tickPathfinding`, player positions with
`Player.tickPosition`, conversations with `Conversation.tick`, and `Agent.tick` for agent logic.
To avoid running a Convex mutation 60 times per second (which would be expensive and slow), the engine batches up
many ticks into a _step_. AI town runs steps at only 1 time per second. Here's how a step works:
1. Load the game state into memory.
2. Decide how long to run.
3. Execute many ticks for our time interval, alternating between feeding in inputs with `handleInput` and advancing
the simulation with `tick`.
4. Write the updated game state back to the database.
One core invariant is that the game engine is fully "single-threaded" per world, so there are never two runs of
an engine's step overlapping in time. Not having to think about race conditions or concurrency makes writing game
engine code a lot easier.
However, preserving this invariant is a little tricky. If the engine is idle for a minute and an
input comes in, we want to run the engine immediately but then cancel its run after the minute's
up. If we're not careful, a race condition may cause us to run multiple copies of the engine if an
input comes in just as an idle timeout is expiring!
Our approach is to store a generation number with the engine that monotonically increases over time.
All scheduled runs of the engine contain their expected generation number as an argument. Then, if
we'd like to cancel a future run of the engine, we can bump the generation number by one, and then
we're guaranteed that the subsequent run will fail immediately as it'll notice that the engine's
generation number does not match its expected one.
### Engine state management
The `World`, `Player`, `Conversation`, and `Agent` classes coordinate loading data into memory from the database,
modifying it according to the game rules, and serializing it to write back out to the database. Here's the flow:
1. The Convex scheduler calls the `convex/aiTown/main.ts:runStep` action.
2. The `runStep` action calls `convex/aiTown/game.ts:loadWorld` to load the current game state. This query calls
`Game.load`, which loads all of a world's game state from the appropriate tables, and returns a
`GameState` object, which contains serialized versions of all of the players, agents, etc.
3. The `runStep` action passes the `GameState` to the `Game` constructor, which parses the serialized versions
of all our game objects using their constructors. For example, `new Player(serializedPlayer)` parses the
database representation into the in-memory `Player` class.
4. The engine runs the simulation, modifying the in-memory game objects.
5. At the end of a step, the framework calls `Game.saveStep`, which computes a diff of the game state since
the beginning of the step and passes the diff to the `convex/aiTown/game.ts:saveWorld` mutation.
6. The `saveWorld` mutation applies the diff to the database, notices if any deleted objects need to be archived,
updates the `participatedTogether` graph, and kicks off any scheduled jobs to run.
7. Since the engine is the only mutator of game state, it continues to run steps for some amount of time
without repeating steps 1 to 3 again.
Just as we assume that the game engine is "single threaded", we also assume that the game engine _exclusively_
owns the tables that store game engine state. Only the game engine should programmatically modify these tables,
so components outside the engine can only mutate them by sending inputs.
### Historical tables
If we're only writing updates out to the database at the end of the step, and steps are only running at once per
second, continuous quantities like position will only update every second. This, then, defeats the whole purpose
of having high-frequency ticks: Player positions will jump around and look choppy.
To solve this, we track the historical values of quantities like position _within_ a step, storing the value
at the end of each tick. Then, the client receives both the current value _and_ the past step's worth of
history, and it can "replay" the history to make the motion smooth.
The game tracks these quantities at the end of each tick by feeding them to a `HistoricalObject`. This object
efficiently tracks its changes over time and serializes them into a buffer that clients can use for replaying
its history. There are a few limitations on `HistoricalObject`:
- Historical objects can only have numeric (floating point) values and can't have nested objects or optional fields.
- Historical objects must declare which fields they'd like to track.
We store each player's "location" (i.e. its position, orientation, and speed) in a `HistoricalObject` and
write it to the `worlds` document at the end of a step when computing a diff.
## Client-side game UI (`src/`)
One guiding principle for AI Town's architecture is to keep the usage as close to "regular Convex" usage as possible. So,
game state is stored in regular tables, and the UI just uses regular `useQuery` hooks to load that state and render
it in the UI.
The one exception is for historical tables, which feed in the latest state into a `useHistoricalValue` hook that parses
the history buffer and replays time forward for smooth motion. To keep replayed time synchronized across multiple
historical buffers, we provide a `useHistoricalTime` hook for the top of your app that keeps track of the current
time and returns it for you to pass down into components.
We also provide a `useSendInput` hook that wraps `useMutation` and automatically sends inputs to the server and
waits for the engine to process them and return their outcome.
## Agent architecture (`convex/agent`)
### The agent loop (`convex/game/agents.ts`)
Agents will execute any game state changes, and schedule operations to do anything that requires
a long-lived request or accessing non-game tables. The flow generally is:
1. Logic in `Agent.tick` can read and modify game state as time progresses, such as waiting until
the agent is near another player to start talking.
2. When there is something that needs to talk to an LLM or read/write external data,
it calls `startOperation` with a reference to a Convex function: generally an `internalAction`.
3. This function can read state from game tables and other tables via `internalQuery` functions.
4. It executes long-running tasks, and can write data via `internalMutation`s.
Game state should not be written, but rather submitted via `inputs` (described in a previous section).
5. Inputs are submitted from actions with `ctx.runMutation(api.game.main.sendInput, {...})` from actions
or via `insertInput` from mutations. They are referenced by their name as a string, like `moveTo`.
6. Inputs are defined with `inputHandler` and are given an instance of the AiTown game to modify,
similar to the game loop. In fact, these are called as part of the game loop before `tickAgent`.
7. When an operation is done, it deletes the `inProgressOperation`. This is to ensure an agent only
is trying to do one thing at a time.
8. `Agent.tick` then can observe the new game state and continue to make decisions.
### Conversations (`convex/agent/conversations.ts`)
The agent code calls into the conversation layer which implements the prompt engineering for
injecting personality and memories into the GPT responses. It has functions for starting a
conversation (`startConversation`), continuing after the first message (`continueConversation`), and
politely leaving a conversation (`leaveConversation`). Each function loads structured data from the
database, queries the memory layer for the agent's opinion about the player they're talking with,
and then calls into the OpenAI client (`convex/util/openai.ts`).
### Memories (`convex/agent/memory.ts`)
After each conversation, GPT summarizes its message history, and we compute an embedding of the
summary text and write it into Convex's vector database. Then, when starting a new conversation
with, Danny, we embed "What you think about Danny?", find the three most similar memories, and fetch
their summary texts to inject into the conversation prompt.
### Embeddings cache (`convex/agent/embeddingsCache.ts`)
To avoid computing the same embedding over and over again, we cache embeddings by a hash of their
text in a Convex table.
## Design goals and limitations
AI Town's game engine has a few design goals:
- Try to be as close to a regular Convex app as possible. Use regular client hooks (like `useQuery`)
when possible, and store game state in regular tables.
- Be as similar to existing engines as possible, so it's easy to change the behavior. We chose a
`tick()` based model for simulation since it's commonly used elsewhere and intuitive.
- Decouple agent behavior from the game engine. It's nice to allow human players and AI agents to do
all the same things in the game.
These design goals imply some inherent limitations:
- All data is loaded into memory each step. The active game state loaded by the game should be small
enough to fit into memory and load and save frequently. Try to keep game state to less than a few dozen
kilobytes: Games that require tens of thousands of objects interacting together may not be a good
fit.
- All inputs are fed through the database in the `inputs` table, so applications that require very
large or frequent inputs may not be a good fit.
- Input latency will be around one RTT (time for the input to make it to the server and the response
to come back) plus half the step size (for expected server input delay when the input's waiting
for the next step). Historical values add another half step size of input latency since their
values are viewed slightly in the past. As configured, this will roughly be around 1.5s of input
latency, which won't be a good fit for competitive games. You can configure the step size to be
smaller (e.g. 250ms) which will decrease input latency at the cost of adding more Convex function
calls and database bandwidth.
- The game engine is designed to be single threaded. JavaScript operating over plain objects
in-memory can be surprisingly fast, but if your simulation is very computationally expensive, it
may not be a good fit on AI Town's engine today.
================================================
FILE: Dockerfile
================================================
# Use an Ubuntu base image
FROM ubuntu:22.04
# Install dependencies
RUN apt-get update && \
apt-get install -y \
curl \
python3 \
python3-pip \
unzip \
socat \
build-essential \
libssl-dev \
iproute2 \
&& rm -rf /var/lib/apt/lists/*
# Install NVM, Node.js, and npm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash && \
export NVM_DIR="$HOME/.nvm" && \
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && \
nvm install 18 && \
nvm use 18
# Add NVM to PATH
ENV NVM_DIR /root/.nvm
ENV NODE_VERSION 18.0.0
RUN . $NVM_DIR/nvm.sh && nvm install $NODE_VERSION
ENV NODE_PATH $NVM_DIR/versions/node/v$NODE_VERSION/lib/node_modules
ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
# Set the working directory
WORKDIR /usr/src/app
# Copy dependency files
COPY package*.json ./
# Install npm dependencies
RUN npm install
RUN npx update-browserslist-db@latest
# Copy application files
COPY . .
# Expose necessary ports
EXPOSE 5173
CMD ["npx", "vite", "--host"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 a16z-infra
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# AI Town 🏠💻💌
[Live Demo](https://www.convex.dev/ai-town)
[Join our community Discord: AI Stack Devs](https://discord.gg/PQUmTBTGmT)
<img width="1454" alt="Screen Shot 2023-08-14 at 10 01 00 AM" src="https://github.com/a16z-infra/ai-town/assets/3489963/a4c91f17-23ed-47ec-8c4e-9f9a8505057d">
AI Town is a virtual town where AI characters live, chat and socialize.
This project is a deployable starter kit for easily building and customizing your own version of AI
town. Inspired by the research paper
[_Generative Agents: Interactive Simulacra of Human Behavior_](https://arxiv.org/pdf/2304.03442.pdf).
The primary goal of this project, beyond just being a lot of fun to work on, is to provide a
platform with a strong foundation that is meant to be extended. The back-end natively supports
shared global state, transactions, and a simulation engine and should be suitable from everything
from a simple project to play around with to a scalable, multi-player game. A secondary goal is to
make a JS/TS framework available as most simulators in this space (including the original paper
above) are written in Python.
## Overview
- 💻 [Stack](#stack)
- 🧠 [Installation](#installation) (cloud, local, Docker, self-host, Fly.io, ...)
- 💻️ [Windows Pre-requisites](#windows-installation)
- 🤖 [Configure your LLM of choice](#connect-an-llm) (Ollama, OpenAI, Together.ai, ...)
- 👤 [Customize - YOUR OWN simulated world](#customize-your-own-simulation)
- 👩💻 [Deploying to production](#deploy-the-app-to-production)
- 🐛 [Troubleshooting](#troubleshooting)
## Stack
- Game engine, database, and vector search: [Convex](https://convex.dev/)
- Auth (Optional): [Clerk](https://clerk.com/)
- Default chat model is `llama3` and embeddings with `mxbai-embed-large`.
- Local inference: [Ollama](https://github.com/jmorganca/ollama)
- Configurable for other cloud LLMs: [Together.ai](https://together.ai/) or anything that speaks the
[OpenAI API](https://platform.openai.com/). PRs welcome to add more cloud provider support.
- Background Music Generation: [Replicate](https://replicate.com/) using
[MusicGen](https://huggingface.co/spaces/facebook/MusicGen)
Other credits:
- Pixel Art Generation: [Replicate](https://replicate.com/),
[Fal.ai](https://serverless.fal.ai/lora)
- All interactions, background music and rendering on the <Game/> component in the project are
powered by [PixiJS](https://pixijs.com/).
- Tilesheet:
- https://opengameart.org/content/16x16-game-assets by George Bailey
- https://opengameart.org/content/16x16-rpg-tileset by hilau
- We used https://github.com/pierpo/phaser3-simple-rpg for the original POC of this project. We have
since re-wrote the whole app, but appreciated the easy starting point
- Original assets by [ansimuz](https://opengameart.org/content/tiny-rpg-forest)
- The UI is based on original assets by
[Mounir Tohami](https://mounirtohami.itch.io/pixel-art-gui-elements)
# Installation
The overall steps are:
1. [Build and deploy](#build-and-deploy)
2. [Connect it to an LLM](#connect-an-llm)
## Build and Deploy
There are a few ways to run the app on top of Convex (the backend).
1. The standard Convex setup, where you develop locally or in the cloud. This requires a Convex
account(free). This is the easiest way to depoy it to the cloud and seriously develop.
2. If you want to try it out without an account and you're okay with Docker, the Docker Compose
setup is nice and self-contained.
3. There's a community fork of this project offering a one-click install on
[Pinokio](https://pinokio.computer/item?uri=https://github.com/cocktailpeanutlabs/aitown) for
anyone interested in running but not modifying it 😎.
4. You can also deploy it to [Fly.io](https://fly.io/). See [./fly](./fly) for instructions.
### Standard Setup
Note, if you're on Windows, see [below](#windows-installation).
```sh
git clone https://github.com/a16z-infra/ai-town.git
cd ai-town
npm install
```
This will require logging into your Convex account, if you haven't already.
To run it:
```sh
npm run dev
```
You can now visit http://localhost:5173.
If you'd rather run the frontend and backend separately (which syncs your backend functions as
they're saved), you can run these in two terminals:
```bash
npm run dev:frontend
npm run dev:backend
```
See [package.json](./package.json) for details.
### Using Docker Compose with self-hosted Convex
You can also run the Convex backend with the self-hosted Docker container. Here we'll set it up to
run the frontend, backend, and dashboard all via docker compose.
```sh
docker compose up --build -d
```
The container will keep running in the background if you pass `-d`. After you've done it once, you
can `stop` and `start` services.
- The frontend will be running on http://localhost:5173.
- The backend will be running on http://localhost:3210 (3211 for the http api).
- The dashboard will be running on http://localhost:6791.
To log into the dashboard and deploy from the convex CLI, you will need to generate an admin key.
```sh
docker compose exec backend ./generate_admin_key.sh
```
Add it to your `.env.local` file. Note: If you run `down` and `up`, you'll have to generate the key
again and update the `.env.local` file.
```sh
# in .env.local
CONVEX_SELF_HOSTED_ADMIN_KEY="<admin-key>" # Ensure there are quotes around it
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:3210"
```
Then set up the Convex backend (one time):
```sh
npm run predev
```
To continuously deploy new code to the backend and print logs:
```sh
npm run dev:backend
```
To see the dashboard, visit `http://localhost:6791` and provide the admin key you generated earlier.
### Configuring Docker for Ollama
If you'll be using Ollama for local inference, you'll need to configure Docker to connect to it.
```sh
npx convex env set OLLAMA_HOST http://host.docker.internal:11434
```
To test the connection (after you [have it running](#ollama-default)):
```sh
docker compose exec backend /bin/bash curl http://host.docker.internal:11434
```
If it says "Ollama is running", it's good! Otherwise, check out the
[Troubleshooting](#troubleshooting) section.
## Connect an LLM
Note: If you want to run the backend in the cloud, you can either use a cloud-based LLM API, like
OpenAI or Together.ai or you can proxy the traffic from the cloud to your local Ollama. See
[below](#using-local-inference-from-a-cloud-deployment) for instructions.
### Ollama (default)
By default, the app tries to use Ollama to run it entirely locally.
1. Download and install [Ollama](https://ollama.com/).
2. Open the app or run `ollama serve` in a terminal. `ollama serve` will warn you if the app is
already running.
3. Run `ollama pull llama3` to have it download `llama3`.
4. Test it out with `ollama run llama3`.
Ollama model options can be found [here](https://ollama.ai/library).
If you want to customize which model to use, adjust convex/util/llm.ts or set
`npx convex env set OLLAMA_MODEL # model`. If you want to edit the embedding model:
1. Change the `OLLAMA_EMBEDDING_DIMENSION` in `convex/util/llm.ts` and ensure:
`export const EMBEDDING_DIMENSION = OLLAMA_EMBEDDING_DIMENSION;`
2. Set `npx convex env set OLLAMA_EMBEDDING_MODEL # model`.
Note: You might want to set `NUM_MEMORIES_TO_SEARCH` to `1` in constants.ts, to reduce the size of
conversation prompts, if you see slowness.
### OpenAI
To use OpenAI, you need to:
```ts
// In convex/util/llm.ts change the following line:
export const EMBEDDING_DIMENSION = OPENAI_EMBEDDING_DIMENSION;
```
Set the `OPENAI_API_KEY` environment variable. Visit https://platform.openai.com/account/api-keys if
you don't have one.
```sh
npx convex env set OPENAI_API_KEY 'your-key'
```
Optional: choose models with `OPENAI_CHAT_MODEL` and `OPENAI_EMBEDDING_MODEL`.
### Together.ai
To use Together.ai, you need to:
```ts
// In convex/util/llm.ts change the following line:
export const EMBEDDING_DIMENSION = TOGETHER_EMBEDDING_DIMENSION;
```
Set the `TOGETHER_API_KEY` environment variable. Visit https://api.together.xyz/settings/api-keys if
you don't have one.
```sh
npx convex env set TOGETHER_API_KEY 'your-key'
```
Optional: choose models via `TOGETHER_CHAT_MODEL`, `TOGETHER_EMBEDDING_MODEL`. The embedding model's
dimension must match `EMBEDDING_DIMENSION`.
### Other OpenAI-compatible API
You can use any OpenAI-compatible API, such as Anthropic, Groq, or Azure.
- Change the `EMBEDDING_DIMENSION` in `convex/util/llm.ts` to match the dimension of your embedding
model.
- Edit `getLLMConfig` in `llm.ts` or set environment variables:
```sh
npx convex env set LLM_API_URL 'your-url'
npx convex env set LLM_API_KEY 'your-key'
npx convex env set LLM_MODEL 'your-chat-model'
npx convex env set LLM_EMBEDDING_MODEL 'your-embedding-model'
```
Note: if `LLM_API_KEY` is not required, don't set it.
### Note on changing the LLM provider or embedding model:
If you change the LLM provider or embedding model, you should delete your data and start over. The
embeddings used for memory are based on the embedding model you choose, and the dimension of the
vector database must match the embedding model's dimension. See
[below](#wiping-the-database-and-starting-over) for how to do that.
## Customize your own simulation
NOTE: every time you change character data, you should re-run `npx convex run testing:wipeAllTables`
and then `npm run dev` to re-upload everything to Convex. This is because character data is sent to
Convex on the initial load. However, beware that `npx convex run testing:wipeAllTables` WILL wipe
all of your data.
1. Create your own characters and stories: All characters and stories, as well as their spritesheet
references are stored in [characters.ts](./data/characters.ts). You can start by changing
character descriptions.
2. Updating spritesheets: in `data/characters.ts`, you will see this code:
```ts
export const characters = [
{
name: 'f1',
textureUrl: '/assets/32x32folk.png',
spritesheetData: f1SpritesheetData,
speed: 0.1,
},
...
];
```
You should find a sprite sheet for your character, and define sprite motion / assets in the
corresponding file (in the above example, `f1SpritesheetData` was defined in f1.ts)
3. Update the Background (Environment): The map gets loaded in `convex/init.ts` from
`data/gentle.js`. To update the map, follow these steps:
- Use [Tiled](https://www.mapeditor.org/) to export tilemaps as a JSON file (2 layers named
bgtiles and objmap)
- Use the `convertMap.js` script to convert the JSON to a format that the engine can use.
```console
node data/convertMap.js <mapDataPath> <assetPath> <tilesetpxw> <tilesetpxh>
```
- `<mapDataPath>`: Path to the Tiled JSON file.
- `<assetPath>`: Path to tileset images.
- `<tilesetpxw>`: Tileset width in pixels.
- `<tilesetpxh>`: Tileset height in pixels. Generates `converted-map.js` that you can use like
`gentle.js`
4. Adding background music with Replicate (Optional)
For Daily background music generation, create a [Replicate](https://replicate.com/) account and
create a token in your Profile's [API Token page](https://replicate.com/account/api-tokens).
`npx convex env set REPLICATE_API_TOKEN # token`
This only works if you can receive the webhook from Replicate. If it's running in the normal
Convex cloud, it will work by default. If you're self-hosting, you'll need to configure it to hit
your app's url on `/http`. If you're using Docker Compose, it will be `http://localhost:3211`,
but you'll need to proxy the traffic to your local machine.
**Note**: The simulation will pause after 5 minutes if the window is idle. Loading the page will
unpause it. You can also manually freeze & unfreeze the world with a button in the UI. If you
want to run the world without the browser, you can comment-out the "stop inactive worlds" cron in
`convex/crons.ts`.
- Change the background music by modifying the prompt in `convex/music.ts`
- Change how often to generate new music at `convex/crons.ts` by modifying the
`generate new background music` job
## Commands to run / test / debug
**To stop the back end, in case of too much activity**
This will stop running the engine and agents. You can still run queries and run functions to debug.
```bash
npx convex run testing:stop
```
**To restart the back end after stopping it**
```bash
npx convex run testing:resume
```
**To kick the engine in case the game engine or agents aren't running**
```bash
npx convex run testing:kick
```
**To archive the world**
If you'd like to reset the world and start from scratch, you can archive the current world:
```bash
npx convex run testing:archive
```
Then, you can still look at the world's data in the dashboard, but the engine and agents will no
longer run.
You can then create a fresh world with `init`.
```bash
npx convex run init
```
**To pause your backend deployment**
You can go to the [dashboard](https://dashboard.convex.dev) to your deployment settings to pause and
un-pause your deployment. This will stop all functions, whether invoked from the client, scheduled,
or as a cron job. See this as a last resort, as there are gentler ways of stopping above.
## Windows Installation
### Prerequisites
1. **Windows 10/11 with WSL2 installed**
2. **Internet connection**
Steps:
1. Install WSL2
First, you need to install WSL2. Follow
[this guide](https://docs.microsoft.com/en-us/windows/wsl/install) to set up WSL2 on your Windows
machine. We recommend using Ubuntu as your Linux distribution.
2. Update Packages
Open your WSL terminal (Ubuntu) and update your packages:
```sh
sudo apt update
```
3. Install NVM and Node.js
NVM (Node Version Manager) helps manage multiple versions of Node.js. Install NVM and Node.js 18
(the stable version):
```sh
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
source ~/.bashrc
nvm install 18
nvm use 18
```
4. Install Python and Pip
Python is required for some dependencies. Install Python and Pip:
```sh
sudo apt-get install python3 python3-pip sudo ln -s /usr/bin/python3 /usr/bin/python
```
At this point, you can follow the instructions [above](#installation).
## Deploy the app to production
### Deploy Convex functions to prod environment
Before you can run the app, you will need to make sure the Convex functions are deployed to its
production environment. Note: this is assuming you're using the default Convex cloud product.
1. Run `npx convex deploy` to deploy the convex functions to production
2. Run `npx convex run init --prod`
To transfer your local data to the cloud, you can run `npx convex export` and then import it with
`npx convex import --prod`.
If you have existing data you want to clear, you can run
`npx convex run testing:wipeAllTables --prod`
### Adding Auth (Optional)
You can add clerk auth back in with `git revert b44a436`. Or just look at that diff for what changed
to remove it.
**Make a Clerk account**
- Go to https://dashboard.clerk.com/ and click on "Add Application"
- Name your application and select the sign-in providers you would like to offer users
- Create Application
- Add `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` to `.env.local`
```bash
VITE_CLERK_PUBLISHABLE_KEY=pk_***
CLERK_SECRET_KEY=sk_***
```
- Go to JWT Templates and create a new Convex Template.
- Copy the JWKS endpoint URL for use below.
```sh
npx convex env set CLERK_ISSUER_URL # e.g. https://your-issuer-url.clerk.accounts.dev/
```
### Deploy the frontend to Vercel
- Register an account on Vercel and then [install the Vercel CLI](https://vercel.com/docs/cli).
- **If you are using Github Codespaces**: You will need to
[install the Vercel CLI](https://vercel.com/docs/cli) and authenticate from your codespaces cli by
running `vercel login`.
- Deploy the app to Vercel with `vercel --prod`.
## Using local inference from a cloud deployment
We support using [Ollama](https://github.com/jmorganca/ollama) for conversation generations. To have
it accessible from the web, you can use Tunnelmole or Ngrok or similar so the cloud backend can send
requests to Ollama running on your local machine.
Steps:
1. Set up either Tunnelmole or Ngrok.
2. Add Ollama endpoint to Convex
```sh
npx convex env set OLLAMA_HOST # your tunnelmole/ngrok unique url from the previous step
```
3. Update Ollama domains Ollama has a list of accepted domains. Add the ngrok domain so it won't
reject traffic. see [ollama.ai](https://ollama.ai) for more details.
### Using Tunnelmole
[Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool.
You can install Tunnelmole using one of the following options:
- NPM: `npm install -g tunnelmole`
- Linux: `curl -s https://tunnelmole.com/sh/install-linux.sh | sudo bash`
- Mac:
`curl -s https://tunnelmole.com/sh/install-mac.sh --output install-mac.sh && sudo bash install-mac.sh`
- Windows: Install with NPM, or if you don't have NodeJS installed, download the `exe` file for
Windows [here](https://tunnelmole.com/downloads/tmole.exe) and put it somewhere in your PATH.
Once Tunnelmole is installed, run the following command:
```
tmole 11434
```
Tunnelmole should output a unique url once you run this command.
### Using Ngrok
Ngrok is a popular closed source tunneling tool.
- [Install Ngrok](https://ngrok.com/docs/getting-started/)
Once ngrok is installed and authenticated, run the following command:
```
ngrok http http://localhost:11434
```
Ngrok should output a unique url once you run this command.
## Troubleshooting
### Wiping the database and starting over
You can wipe the database by running:
```sh
npx convex run testing:wipeAllTables
```
Then reset with:
```sh
npx convex run init
```
### Incompatible Node.js versions
If you encounter a node version error on the convex server upon application startup, please use node
version 18, which is the most stable. One way to do this is by
[installing nvm](https://nodejs.org/en/download/package-manager) and running `nvm install 18` and
`nvm use 18`.
### Reaching Ollama
If you're having trouble with the backend communicating with Ollama, it depends on your setup how to
debug:
1. If you're running directly on Windows, see
[Windows Ollama connection issues](#windows-ollama-connection-issues).
2. If you're using **Docker**, see
[Docker to Ollama connection issues](#docker-to-ollama-connection-issues).
3. If you're running locally, you can try the following:
```sh
npx convex env set OLLAMA_HOST http://localhost:11434
```
By default, the host is set to `http://127.0.0.1:11434`. Some systems prefer `localhost`
¯\_(ツ)\_/¯.
### Windows Ollama connection issues
If the above didn't work after following the [windows](#windows-installation) and regular
[installation](#installation) instructions, you can try the following, assuming you're **not** using
Docker.
If you're using Docker, see the [next section](#docker-to-ollama-connection-issues) for Docker
troubleshooting.
For running directly on Windows, you can try the following:
1. Install `unzip` and `socat`:
```sh
sudo apt install unzip socat
```
2. Configure `socat` to Bridge Ports for Ollama
Run the following command to bridge ports:
```sh
socat TCP-LISTEN:11434,fork TCP:$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}'):11434 &
```
3. Test if it's working:
```sh
curl http://127.0.0.1:11434
```
If it responds OK, the Ollama API should be accessible.
### Docker to Ollama connection issues
If you're having trouble with the backend communicating with Ollama, there's a couple things to
check:
1. Is Docker at least verion 18.03 ? That allows you to use the `host.docker.internal` hostname to
connect to the host from inside the container.
2. Is Ollama running? You can check this by running `curl http://localhost:11434` from outside the
container.
3. Is Ollama accessible from inside the container? You can check this by running
`docker compose exec backend curl http://host.docker.internal:11434`.
If 1 & 2 work, but 3 does not, you can use `socat` to bridge the traffic from inside the container
to Ollama running on the host.
1. Configure `socat` with the host's IP address (not the Docker IP).
```sh
docker compose exec backend /bin/bash
HOST_IP=YOUR-HOST-IP
socat TCP-LISTEN:11434,fork TCP:$HOST_IP:11434
```
Keep this running.
2. Then from outside of the container:
```sh
npx convex env set OLLAMA_HOST http://localhost:11434
```
3. Test if it's working:
```sh
docker compose exec backend curl http://localhost:11434
```
If it responds OK, the Ollama API is accessible. Otherwise, try changing the previous two to
`http://127.0.0.1:11434`.
### Launching an Interactive Docker Terminal
If you wan to investigate inside the container, you can launch an interactive Docker terminal, for
the `frontend`, `backend` or `dashboard` service:
```bash
docker compose exec frontend /bin/bash
```
To exit the container, run `exit`.
### Updating the browser list
```bash
docker compose exec frontend npx update-browserslist-db@latest
```
# 🧑🏫 What is Convex?
[Convex](https://convex.dev) is a hosted backend platform with a built-in database that lets you
write your [database schema](https://docs.convex.dev/database/schemas) and
[server functions](https://docs.convex.dev/functions) in
[TypeScript](https://docs.convex.dev/typescript). Server-side database
[queries](https://docs.convex.dev/functions/query-functions) automatically
[cache](https://docs.convex.dev/functions/query-functions#caching--reactivity) and
[subscribe](https://docs.convex.dev/client/react#reactivity) to data, powering a
[realtime `useQuery` hook](https://docs.convex.dev/client/react#fetching-data) in our
[React client](https://docs.convex.dev/client/react). There are also clients for
[Python](https://docs.convex.dev/client/python), [Rust](https://docs.convex.dev/client/rust),
[ReactNative](https://docs.convex.dev/client/react-native), and
[Node](https://docs.convex.dev/client/javascript), as well as a straightforward
[HTTP API](https://docs.convex.dev/http-api/).
The database supports [NoSQL-style documents](https://docs.convex.dev/database/document-storage)
with [opt-in schema validation](https://docs.convex.dev/database/schemas),
[relationships](https://docs.convex.dev/database/document-ids) and
[custom indexes](https://docs.convex.dev/database/indexes/) (including on fields in nested objects).
The [`query`](https://docs.convex.dev/functions/query-functions) and
[`mutation`](https://docs.convex.dev/functions/mutation-functions) server functions have
transactional, low latency access to the database and leverage our
[`v8` runtime](https://docs.convex.dev/functions/runtimes) with
[determinism guardrails](https://docs.convex.dev/functions/runtimes#using-randomness-and-time-in-queries-and-mutations)
to provide the strongest ACID guarantees on the market: immediate consistency, serializable
isolation, and automatic conflict resolution via
[optimistic multi-version concurrency control](https://docs.convex.dev/database/advanced/occ) (OCC /
MVCC).
The [`action` server functions](https://docs.convex.dev/functions/actions) have access to external
APIs and enable other side-effects and non-determinism in either our
[optimized `v8` runtime](https://docs.convex.dev/functions/runtimes) or a more
[flexible `node` runtime](https://docs.convex.dev/functions/runtimes#nodejs-runtime).
Functions can run in the background via
[scheduling](https://docs.convex.dev/scheduling/scheduled-functions) and
[cron jobs](https://docs.convex.dev/scheduling/cron-jobs).
Development is cloud-first, with
[hot reloads for server function](https://docs.convex.dev/cli#run-the-convex-dev-server) editing via
the [CLI](https://docs.convex.dev/cli),
[preview deployments](https://docs.convex.dev/production/hosting/preview-deployments),
[logging and exception reporting integrations](https://docs.convex.dev/production/integrations/),
There is a [dashboard UI](https://docs.convex.dev/dashboard) to
[browse and edit data](https://docs.convex.dev/dashboard/deployments/data),
[edit environment variables](https://docs.convex.dev/production/environment-variables),
[view logs](https://docs.convex.dev/dashboard/deployments/logs),
[run server functions](https://docs.convex.dev/dashboard/deployments/functions), and more.
There are built-in features for [reactive pagination](https://docs.convex.dev/database/pagination),
[file storage](https://docs.convex.dev/file-storage),
[reactive text search](https://docs.convex.dev/text-search),
[vector search](https://docs.convex.dev/vector-search),
[https endpoints](https://docs.convex.dev/functions/http-actions) (for webhooks),
[snapshot import/export](https://docs.convex.dev/database/import-export/),
[streaming import/export](https://docs.convex.dev/production/integrations/streaming-import-export),
and [runtime validation](https://docs.convex.dev/database/schemas#validators) for
[function arguments](https://docs.convex.dev/functions/args-validation) and
[database data](https://docs.convex.dev/database/schemas#schema-validation).
Everything scales automatically, and it’s [free to start](https://www.convex.dev/plans).
================================================
FILE: convex/_generated/api.d.ts
================================================
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import type * as agent_conversation from "../agent/conversation.js";
import type * as agent_embeddingsCache from "../agent/embeddingsCache.js";
import type * as agent_memory from "../agent/memory.js";
import type * as aiTown_agent from "../aiTown/agent.js";
import type * as aiTown_agentDescription from "../aiTown/agentDescription.js";
import type * as aiTown_agentInputs from "../aiTown/agentInputs.js";
import type * as aiTown_agentOperations from "../aiTown/agentOperations.js";
import type * as aiTown_conversation from "../aiTown/conversation.js";
import type * as aiTown_conversationMembership from "../aiTown/conversationMembership.js";
import type * as aiTown_game from "../aiTown/game.js";
import type * as aiTown_ids from "../aiTown/ids.js";
import type * as aiTown_inputHandler from "../aiTown/inputHandler.js";
import type * as aiTown_inputs from "../aiTown/inputs.js";
import type * as aiTown_insertInput from "../aiTown/insertInput.js";
import type * as aiTown_location from "../aiTown/location.js";
import type * as aiTown_main from "../aiTown/main.js";
import type * as aiTown_movement from "../aiTown/movement.js";
import type * as aiTown_player from "../aiTown/player.js";
import type * as aiTown_playerDescription from "../aiTown/playerDescription.js";
import type * as aiTown_world from "../aiTown/world.js";
import type * as aiTown_worldMap from "../aiTown/worldMap.js";
import type * as constants from "../constants.js";
import type * as crons from "../crons.js";
import type * as engine_abstractGame from "../engine/abstractGame.js";
import type * as engine_historicalObject from "../engine/historicalObject.js";
import type * as http from "../http.js";
import type * as init from "../init.js";
import type * as messages from "../messages.js";
import type * as music from "../music.js";
import type * as testing from "../testing.js";
import type * as util_FastIntegerCompression from "../util/FastIntegerCompression.js";
import type * as util_assertNever from "../util/assertNever.js";
import type * as util_asyncMap from "../util/asyncMap.js";
import type * as util_compression from "../util/compression.js";
import type * as util_geometry from "../util/geometry.js";
import type * as util_isSimpleObject from "../util/isSimpleObject.js";
import type * as util_llm from "../util/llm.js";
import type * as util_minheap from "../util/minheap.js";
import type * as util_object from "../util/object.js";
import type * as util_sleep from "../util/sleep.js";
import type * as util_types from "../util/types.js";
import type * as util_xxhash from "../util/xxhash.js";
import type * as world from "../world.js";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{
"agent/conversation": typeof agent_conversation;
"agent/embeddingsCache": typeof agent_embeddingsCache;
"agent/memory": typeof agent_memory;
"aiTown/agent": typeof aiTown_agent;
"aiTown/agentDescription": typeof aiTown_agentDescription;
"aiTown/agentInputs": typeof aiTown_agentInputs;
"aiTown/agentOperations": typeof aiTown_agentOperations;
"aiTown/conversation": typeof aiTown_conversation;
"aiTown/conversationMembership": typeof aiTown_conversationMembership;
"aiTown/game": typeof aiTown_game;
"aiTown/ids": typeof aiTown_ids;
"aiTown/inputHandler": typeof aiTown_inputHandler;
"aiTown/inputs": typeof aiTown_inputs;
"aiTown/insertInput": typeof aiTown_insertInput;
"aiTown/location": typeof aiTown_location;
"aiTown/main": typeof aiTown_main;
"aiTown/movement": typeof aiTown_movement;
"aiTown/player": typeof aiTown_player;
"aiTown/playerDescription": typeof aiTown_playerDescription;
"aiTown/world": typeof aiTown_world;
"aiTown/worldMap": typeof aiTown_worldMap;
constants: typeof constants;
crons: typeof crons;
"engine/abstractGame": typeof engine_abstractGame;
"engine/historicalObject": typeof engine_historicalObject;
http: typeof http;
init: typeof init;
messages: typeof messages;
music: typeof music;
testing: typeof testing;
"util/FastIntegerCompression": typeof util_FastIntegerCompression;
"util/assertNever": typeof util_assertNever;
"util/asyncMap": typeof util_asyncMap;
"util/compression": typeof util_compression;
"util/geometry": typeof util_geometry;
"util/isSimpleObject": typeof util_isSimpleObject;
"util/llm": typeof util_llm;
"util/minheap": typeof util_minheap;
"util/object": typeof util_object;
"util/sleep": typeof util_sleep;
"util/types": typeof util_types;
"util/xxhash": typeof util_xxhash;
world: typeof world;
}>;
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
================================================
FILE: convex/_generated/api.js
================================================
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
================================================
FILE: convex/_generated/dataModel.d.ts
================================================
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
================================================
FILE: convex/_generated/server.d.ts
================================================
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
================================================
FILE: convex/_generated/server.js
================================================
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define a Convex HTTP action.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/
export const httpAction = httpActionGeneric;
================================================
FILE: convex/agent/conversation.ts
================================================
import { v } from 'convex/values';
import { Id } from '../_generated/dataModel';
import { ActionCtx, internalQuery } from '../_generated/server';
import { LLMMessage, chatCompletion } from '../util/llm';
import * as memory from './memory';
import { api, internal } from '../_generated/api';
import * as embeddingsCache from './embeddingsCache';
import { GameId, conversationId, playerId } from '../aiTown/ids';
import { NUM_MEMORIES_TO_SEARCH } from '../constants';
const selfInternal = internal.agent.conversation;
export async function startConversationMessage(
ctx: ActionCtx,
worldId: Id<'worlds'>,
conversationId: GameId<'conversations'>,
playerId: GameId<'players'>,
otherPlayerId: GameId<'players'>,
): Promise<string> {
const { player, otherPlayer, agent, otherAgent, lastConversation } = await ctx.runQuery(
selfInternal.queryPromptData,
{
worldId,
playerId,
otherPlayerId,
conversationId,
},
);
const embedding = await embeddingsCache.fetch(
ctx,
`${player.name} is talking to ${otherPlayer.name}`,
);
const memories = await memory.searchMemories(
ctx,
player.id as GameId<'players'>,
embedding,
Number(process.env.NUM_MEMORIES_TO_SEARCH) || NUM_MEMORIES_TO_SEARCH,
);
const memoryWithOtherPlayer = memories.find(
(m) => m.data.type === 'conversation' && m.data.playerIds.includes(otherPlayerId),
);
const prompt = [
`You are ${player.name}, and you just started a conversation with ${otherPlayer.name}.`,
];
prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null));
prompt.push(...previousConversationPrompt(otherPlayer, lastConversation));
prompt.push(...relatedMemoriesPrompt(memories));
if (memoryWithOtherPlayer) {
prompt.push(
`Be sure to include some detail or question about a previous conversation in your greeting.`,
);
}
const lastPrompt = `${player.name} to ${otherPlayer.name}:`;
prompt.push(lastPrompt);
const { content } = await chatCompletion({
messages: [
{
role: 'system',
content: prompt.join('\n'),
},
],
max_tokens: 300,
stop: stopWords(otherPlayer.name, player.name),
});
return trimContentPrefx(content, lastPrompt);
}
function trimContentPrefx(content: string, prompt: string) {
if (content.startsWith(prompt)) {
return content.slice(prompt.length).trim();
}
return content;
}
export async function continueConversationMessage(
ctx: ActionCtx,
worldId: Id<'worlds'>,
conversationId: GameId<'conversations'>,
playerId: GameId<'players'>,
otherPlayerId: GameId<'players'>,
): Promise<string> {
const { player, otherPlayer, conversation, agent, otherAgent } = await ctx.runQuery(
selfInternal.queryPromptData,
{
worldId,
playerId,
otherPlayerId,
conversationId,
},
);
const now = Date.now();
const started = new Date(conversation.created);
const embedding = await embeddingsCache.fetch(
ctx,
`What do you think about ${otherPlayer.name}?`,
);
const memories = await memory.searchMemories(ctx, player.id as GameId<'players'>, embedding, 3);
const prompt = [
`You are ${player.name}, and you're currently in a conversation with ${otherPlayer.name}.`,
`The conversation started at ${started.toLocaleString()}. It's now ${now.toLocaleString()}.`,
];
prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null));
prompt.push(...relatedMemoriesPrompt(memories));
prompt.push(
`Below is the current chat history between you and ${otherPlayer.name}.`,
`DO NOT greet them again. Do NOT use the word "Hey" too often. Your response should be brief and within 200 characters.`,
);
const llmMessages: LLMMessage[] = [
{
role: 'system',
content: prompt.join('\n'),
},
...(await previousMessages(
ctx,
worldId,
player,
otherPlayer,
conversation.id as GameId<'conversations'>,
)),
];
const lastPrompt = `${player.name} to ${otherPlayer.name}:`;
llmMessages.push({ role: 'user', content: lastPrompt });
const { content } = await chatCompletion({
messages: llmMessages,
max_tokens: 300,
stop: stopWords(otherPlayer.name, player.name),
});
return trimContentPrefx(content, lastPrompt);
}
export async function leaveConversationMessage(
ctx: ActionCtx,
worldId: Id<'worlds'>,
conversationId: GameId<'conversations'>,
playerId: GameId<'players'>,
otherPlayerId: GameId<'players'>,
): Promise<string> {
const { player, otherPlayer, conversation, agent, otherAgent } = await ctx.runQuery(
selfInternal.queryPromptData,
{
worldId,
playerId,
otherPlayerId,
conversationId,
},
);
const prompt = [
`You are ${player.name}, and you're currently in a conversation with ${otherPlayer.name}.`,
`You've decided to leave the question and would like to politely tell them you're leaving the conversation.`,
];
prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null));
prompt.push(
`Below is the current chat history between you and ${otherPlayer.name}.`,
`How would you like to tell them that you're leaving? Your response should be brief and within 200 characters.`,
);
const llmMessages: LLMMessage[] = [
{
role: 'system',
content: prompt.join('\n'),
},
...(await previousMessages(
ctx,
worldId,
player,
otherPlayer,
conversation.id as GameId<'conversations'>,
)),
];
const lastPrompt = `${player.name} to ${otherPlayer.name}:`;
llmMessages.push({ role: 'user', content: lastPrompt });
const { content } = await chatCompletion({
messages: llmMessages,
max_tokens: 300,
stop: stopWords(otherPlayer.name, player.name),
});
return trimContentPrefx(content, lastPrompt);
}
function agentPrompts(
otherPlayer: { name: string },
agent: { identity: string; plan: string } | null,
otherAgent: { identity: string; plan: string } | null,
): string[] {
const prompt = [];
if (agent) {
prompt.push(`About you: ${agent.identity}`);
prompt.push(`Your goals for the conversation: ${agent.plan}`);
}
if (otherAgent) {
prompt.push(`About ${otherPlayer.name}: ${otherAgent.identity}`);
}
return prompt;
}
function previousConversationPrompt(
otherPlayer: { name: string },
conversation: { created: number } | null,
): string[] {
const prompt = [];
if (conversation) {
const prev = new Date(conversation.created);
const now = new Date();
prompt.push(
`Last time you chatted with ${
otherPlayer.name
} it was ${prev.toLocaleString()}. It's now ${now.toLocaleString()}.`,
);
}
return prompt;
}
function relatedMemoriesPrompt(memories: memory.Memory[]): string[] {
const prompt = [];
if (memories.length > 0) {
prompt.push(`Here are some related memories in decreasing relevance order:`);
for (const memory of memories) {
prompt.push(' - ' + memory.description);
}
}
return prompt;
}
async function previousMessages(
ctx: ActionCtx,
worldId: Id<'worlds'>,
player: { id: string; name: string },
otherPlayer: { id: string; name: string },
conversationId: GameId<'conversations'>,
) {
const llmMessages: LLMMessage[] = [];
const prevMessages = await ctx.runQuery(api.messages.listMessages, { worldId, conversationId });
for (const message of prevMessages) {
const author = message.author === player.id ? player : otherPlayer;
const recipient = message.author === player.id ? otherPlayer : player;
llmMessages.push({
role: 'user',
content: `${author.name} to ${recipient.name}: ${message.text}`,
});
}
return llmMessages;
}
export const queryPromptData = internalQuery({
args: {
worldId: v.id('worlds'),
playerId,
otherPlayerId: playerId,
conversationId,
},
handler: async (ctx, args) => {
const world = await ctx.db.get(args.worldId);
if (!world) {
throw new Error(`World ${args.worldId} not found`);
}
const player = world.players.find((p) => p.id === args.playerId);
if (!player) {
throw new Error(`Player ${args.playerId} not found`);
}
const playerDescription = await ctx.db
.query('playerDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId))
.first();
if (!playerDescription) {
throw new Error(`Player description for ${args.playerId} not found`);
}
const otherPlayer = world.players.find((p) => p.id === args.otherPlayerId);
if (!otherPlayer) {
throw new Error(`Player ${args.otherPlayerId} not found`);
}
const otherPlayerDescription = await ctx.db
.query('playerDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.otherPlayerId))
.first();
if (!otherPlayerDescription) {
throw new Error(`Player description for ${args.otherPlayerId} not found`);
}
const conversation = world.conversations.find((c) => c.id === args.conversationId);
if (!conversation) {
throw new Error(`Conversation ${args.conversationId} not found`);
}
const agent = world.agents.find((a) => a.playerId === args.playerId);
if (!agent) {
throw new Error(`Player ${args.playerId} not found`);
}
const agentDescription = await ctx.db
.query('agentDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('agentId', agent.id))
.first();
if (!agentDescription) {
throw new Error(`Agent description for ${agent.id} not found`);
}
const otherAgent = world.agents.find((a) => a.playerId === args.otherPlayerId);
let otherAgentDescription;
if (otherAgent) {
otherAgentDescription = await ctx.db
.query('agentDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('agentId', otherAgent.id))
.first();
if (!otherAgentDescription) {
throw new Error(`Agent description for ${otherAgent.id} not found`);
}
}
const lastTogether = await ctx.db
.query('participatedTogether')
.withIndex('edge', (q) =>
q
.eq('worldId', args.worldId)
.eq('player1', args.playerId)
.eq('player2', args.otherPlayerId),
)
// Order by conversation end time descending.
.order('desc')
.first();
let lastConversation = null;
if (lastTogether) {
lastConversation = await ctx.db
.query('archivedConversations')
.withIndex('worldId', (q) =>
q.eq('worldId', args.worldId).eq('id', lastTogether.conversationId),
)
.first();
if (!lastConversation) {
throw new Error(`Conversation ${lastTogether.conversationId} not found`);
}
}
return {
player: { name: playerDescription.name, ...player },
otherPlayer: { name: otherPlayerDescription.name, ...otherPlayer },
conversation,
agent: { identity: agentDescription.identity, plan: agentDescription.plan, ...agent },
otherAgent: otherAgent && {
identity: otherAgentDescription!.identity,
plan: otherAgentDescription!.plan,
...otherAgent,
},
lastConversation,
};
},
});
function stopWords(otherPlayer: string, player: string) {
// These are the words we ask the LLM to stop on. OpenAI only supports 4.
const variants = [`${otherPlayer} to ${player}`];
return variants.flatMap((stop) => [stop + ':', stop.toLowerCase() + ':']);
}
================================================
FILE: convex/agent/embeddingsCache.ts
================================================
import { v } from 'convex/values';
import { ActionCtx, internalMutation, internalQuery } from '../_generated/server';
import { internal } from '../_generated/api';
import { Id } from '../_generated/dataModel';
import { fetchEmbeddingBatch } from '../util/llm';
const selfInternal = internal.agent.embeddingsCache;
export async function fetch(ctx: ActionCtx, text: string) {
const result = await fetchBatch(ctx, [text]);
return result.embeddings[0];
}
export async function fetchBatch(ctx: ActionCtx, texts: string[]) {
const start = Date.now();
const textHashes = await Promise.all(texts.map((text) => hashText(text)));
const results = new Array<number[]>(texts.length);
const cacheResults = await ctx.runQuery(selfInternal.getEmbeddingsByText, {
textHashes,
});
for (const { index, embedding } of cacheResults) {
results[index] = embedding;
}
const toWrite = [];
if (cacheResults.length < texts.length) {
const missingIndexes = [...results.keys()].filter((i) => !results[i]);
const missingTexts = missingIndexes.map((i) => texts[i]);
const response = await fetchEmbeddingBatch(missingTexts);
if (response.embeddings.length !== missingIndexes.length) {
throw new Error(
`Expected ${missingIndexes.length} embeddings, got ${response.embeddings.length}`,
);
}
for (let i = 0; i < missingIndexes.length; i++) {
const resultIndex = missingIndexes[i];
toWrite.push({
textHash: textHashes[resultIndex],
embedding: response.embeddings[i],
});
results[resultIndex] = response.embeddings[i];
}
}
if (toWrite.length > 0) {
await ctx.runMutation(selfInternal.writeEmbeddings, { embeddings: toWrite });
}
return {
embeddings: results,
hits: cacheResults.length,
ms: Date.now() - start,
};
}
async function hashText(text: string) {
const textEncoder = new TextEncoder();
const buf = textEncoder.encode(text);
if (typeof crypto === 'undefined') {
// Ugly, ugly hax to get ESBuild to not try to bundle this node dependency.
const f = () => 'node:crypto';
const crypto = (await import(f())) as typeof import('crypto');
const hash = crypto.createHash('sha256');
hash.update(buf);
return hash.digest().buffer;
} else {
return await crypto.subtle.digest('SHA-256', buf);
}
}
export const getEmbeddingsByText = internalQuery({
args: { textHashes: v.array(v.bytes()) },
handler: async (
ctx,
args,
): Promise<{ index: number; embeddingId: Id<'embeddingsCache'>; embedding: number[] }[]> => {
const out = [];
for (let i = 0; i < args.textHashes.length; i++) {
const textHash = args.textHashes[i];
const result = await ctx.db
.query('embeddingsCache')
.withIndex('text', (q) => q.eq('textHash', textHash))
.first();
if (result) {
out.push({
index: i,
embeddingId: result._id,
embedding: result.embedding,
});
}
}
return out;
},
});
export const writeEmbeddings = internalMutation({
args: {
embeddings: v.array(
v.object({
textHash: v.bytes(),
embedding: v.array(v.float64()),
}),
),
},
handler: async (ctx, args): Promise<Id<'embeddingsCache'>[]> => {
const ids = [];
for (const embedding of args.embeddings) {
ids.push(await ctx.db.insert('embeddingsCache', embedding));
}
return ids;
},
});
================================================
FILE: convex/agent/memory.ts
================================================
import { v } from 'convex/values';
import { ActionCtx, DatabaseReader, internalMutation, internalQuery } from '../_generated/server';
import { Doc, Id } from '../_generated/dataModel';
import { internal } from '../_generated/api';
import { LLMMessage, chatCompletion, fetchEmbedding } from '../util/llm';
import { asyncMap } from '../util/asyncMap';
import { GameId, agentId, conversationId, playerId } from '../aiTown/ids';
import { SerializedPlayer } from '../aiTown/player';
import { memoryFields } from './schema';
// How long to wait before updating a memory's last access time.
export const MEMORY_ACCESS_THROTTLE = 300_000; // In ms
// We fetch 10x the number of memories by relevance, to have more candidates
// for sorting by relevance + recency + importance.
const MEMORY_OVERFETCH = 10;
const selfInternal = internal.agent.memory;
export type Memory = Doc<'memories'>;
export type MemoryType = Memory['data']['type'];
export type MemoryOfType<T extends MemoryType> = Omit<Memory, 'data'> & {
data: Extract<Memory['data'], { type: T }>;
};
export async function rememberConversation(
ctx: ActionCtx,
worldId: Id<'worlds'>,
agentId: GameId<'agents'>,
playerId: GameId<'players'>,
conversationId: GameId<'conversations'>,
) {
const data = await ctx.runQuery(selfInternal.loadConversation, {
worldId,
playerId,
conversationId,
});
const { player, otherPlayer } = data;
const messages = await ctx.runQuery(selfInternal.loadMessages, { worldId, conversationId });
if (!messages.length) {
return;
}
const llmMessages: LLMMessage[] = [
{
role: 'user',
content: `You are ${player.name}, and you just finished a conversation with ${otherPlayer.name}. I would
like you to summarize the conversation from ${player.name}'s perspective, using first-person pronouns like
"I," and add if you liked or disliked this interaction.`,
},
];
const authors = new Set<GameId<'players'>>();
for (const message of messages) {
const author = message.author === player.id ? player : otherPlayer;
authors.add(author.id as GameId<'players'>);
const recipient = message.author === player.id ? otherPlayer : player;
llmMessages.push({
role: 'user',
content: `${author.name} to ${recipient.name}: ${message.text}`,
});
}
llmMessages.push({ role: 'user', content: 'Summary:' });
const { content } = await chatCompletion({
messages: llmMessages,
max_tokens: 500,
});
const description = `Conversation with ${otherPlayer.name} at ${new Date(
data.conversation._creationTime,
).toLocaleString()}: ${content}`;
const importance = await calculateImportance(description);
const { embedding } = await fetchEmbedding(description);
authors.delete(player.id as GameId<'players'>);
await ctx.runMutation(selfInternal.insertMemory, {
agentId,
playerId: player.id,
description,
importance,
lastAccess: messages[messages.length - 1]._creationTime,
data: {
type: 'conversation',
conversationId,
playerIds: [...authors],
},
embedding,
});
await reflectOnMemories(ctx, worldId, playerId);
return description;
}
export const loadConversation = internalQuery({
args: {
worldId: v.id('worlds'),
playerId,
conversationId,
},
handler: async (ctx, args) => {
const world = await ctx.db.get(args.worldId);
if (!world) {
throw new Error(`World ${args.worldId} not found`);
}
const player = world.players.find((p) => p.id === args.playerId);
if (!player) {
throw new Error(`Player ${args.playerId} not found`);
}
const playerDescription = await ctx.db
.query('playerDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId))
.first();
if (!playerDescription) {
throw new Error(`Player description for ${args.playerId} not found`);
}
const conversation = await ctx.db
.query('archivedConversations')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('id', args.conversationId))
.first();
if (!conversation) {
throw new Error(`Conversation ${args.conversationId} not found`);
}
const otherParticipator = await ctx.db
.query('participatedTogether')
.withIndex('conversation', (q) =>
q
.eq('worldId', args.worldId)
.eq('player1', args.playerId)
.eq('conversationId', args.conversationId),
)
.first();
if (!otherParticipator) {
throw new Error(
`Couldn't find other participant in conversation ${args.conversationId} with player ${args.playerId}`,
);
}
const otherPlayerId = otherParticipator.player2;
let otherPlayer: SerializedPlayer | Doc<'archivedPlayers'> | null =
world.players.find((p) => p.id === otherPlayerId) ?? null;
if (!otherPlayer) {
otherPlayer = await ctx.db
.query('archivedPlayers')
.withIndex('worldId', (q) => q.eq('worldId', world._id).eq('id', otherPlayerId))
.first();
}
if (!otherPlayer) {
throw new Error(`Conversation ${args.conversationId} other player not found`);
}
const otherPlayerDescription = await ctx.db
.query('playerDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', otherPlayerId))
.first();
if (!otherPlayerDescription) {
throw new Error(`Player description for ${otherPlayerId} not found`);
}
return {
player: { ...player, name: playerDescription.name },
conversation,
otherPlayer: { ...otherPlayer, name: otherPlayerDescription.name },
};
},
});
export async function searchMemories(
ctx: ActionCtx,
playerId: GameId<'players'>,
searchEmbedding: number[],
n: number = 3,
) {
const candidates = await ctx.vectorSearch('memoryEmbeddings', 'embedding', {
vector: searchEmbedding,
filter: (q) => q.eq('playerId', playerId),
limit: n * MEMORY_OVERFETCH,
});
const rankedMemories = await ctx.runMutation(selfInternal.rankAndTouchMemories, {
candidates,
n,
});
return rankedMemories.map(({ memory }) => memory);
}
function makeRange(values: number[]) {
const min = Math.min(...values);
const max = Math.max(...values);
return [min, max] as const;
}
function normalize(value: number, range: readonly [number, number]) {
const [min, max] = range;
return (value - min) / (max - min);
}
export const rankAndTouchMemories = internalMutation({
args: {
candidates: v.array(v.object({ _id: v.id('memoryEmbeddings'), _score: v.number() })),
n: v.number(),
},
handler: async (ctx, args) => {
const ts = Date.now();
const relatedMemories = await asyncMap(args.candidates, async ({ _id }) => {
const memory = await ctx.db
.query('memories')
.withIndex('embeddingId', (q) => q.eq('embeddingId', _id))
.first();
if (!memory) throw new Error(`Memory for embedding ${_id} not found`);
return memory;
});
// TODO: fetch <count> recent memories and <count> important memories
// so we don't miss them in case they were a little less relevant.
const recencyScore = relatedMemories.map((memory) => {
const hoursSinceAccess = (ts - memory.lastAccess) / 1000 / 60 / 60;
return 0.99 ** Math.floor(hoursSinceAccess);
});
const relevanceRange = makeRange(args.candidates.map((c) => c._score));
const importanceRange = makeRange(relatedMemories.map((m) => m.importance));
const recencyRange = makeRange(recencyScore);
const memoryScores = relatedMemories.map((memory, idx) => ({
memory,
overallScore:
normalize(args.candidates[idx]._score, relevanceRange) +
normalize(memory.importance, importanceRange) +
normalize(recencyScore[idx], recencyRange),
}));
memoryScores.sort((a, b) => b.overallScore - a.overallScore);
const accessed = memoryScores.slice(0, args.n);
await asyncMap(accessed, async ({ memory }) => {
if (memory.lastAccess < ts - MEMORY_ACCESS_THROTTLE) {
await ctx.db.patch(memory._id, { lastAccess: ts });
}
});
return accessed;
},
});
export const loadMessages = internalQuery({
args: {
worldId: v.id('worlds'),
conversationId,
},
handler: async (ctx, args): Promise<Doc<'messages'>[]> => {
const messages = await ctx.db
.query('messages')
.withIndex('conversationId', (q) =>
q.eq('worldId', args.worldId).eq('conversationId', args.conversationId),
)
.collect();
return messages;
},
});
async function calculateImportance(description: string) {
const { content: importanceRaw } = await chatCompletion({
messages: [
{
role: 'user',
content: `On the scale of 0 to 9, where 0 is purely mundane (e.g., brushing teeth, making bed) and 9 is extremely poignant (e.g., a break up, college acceptance), rate the likely poignancy of the following piece of memory.
Memory: ${description}
Answer on a scale of 0 to 9. Respond with number only, e.g. "5"`,
},
],
temperature: 0.0,
max_tokens: 1,
});
let importance = parseFloat(importanceRaw);
if (isNaN(importance)) {
importance = +(importanceRaw.match(/\d+/)?.[0] ?? NaN);
}
if (isNaN(importance)) {
console.debug('Could not parse memory importance from: ', importanceRaw);
importance = 5;
}
return importance;
}
const { embeddingId: _embeddingId, ...memoryFieldsWithoutEmbeddingId } = memoryFields;
export const insertMemory = internalMutation({
args: {
agentId,
embedding: v.array(v.float64()),
...memoryFieldsWithoutEmbeddingId,
},
handler: async (ctx, { agentId: _, embedding, ...memory }): Promise<void> => {
const embeddingId = await ctx.db.insert('memoryEmbeddings', {
playerId: memory.playerId,
embedding,
});
await ctx.db.insert('memories', {
...memory,
embeddingId,
});
},
});
export const insertReflectionMemories = internalMutation({
args: {
worldId: v.id('worlds'),
playerId,
reflections: v.array(
v.object({
description: v.string(),
relatedMemoryIds: v.array(v.id('memories')),
importance: v.number(),
embedding: v.array(v.float64()),
}),
),
},
handler: async (ctx, { playerId, reflections }) => {
const lastAccess = Date.now();
for (const { embedding, relatedMemoryIds, ...rest } of reflections) {
const embeddingId = await ctx.db.insert('memoryEmbeddings', {
playerId,
embedding,
});
await ctx.db.insert('memories', {
playerId,
embeddingId,
lastAccess,
...rest,
data: {
type: 'reflection',
relatedMemoryIds,
},
});
}
},
});
async function reflectOnMemories(
ctx: ActionCtx,
worldId: Id<'worlds'>,
playerId: GameId<'players'>,
) {
const { memories, lastReflectionTs, name } = await ctx.runQuery(
internal.agent.memory.getReflectionMemories,
{
worldId,
playerId,
numberOfItems: 100,
},
);
// should only reflect if lastest 100 items have importance score of >500
const sumOfImportanceScore = memories
.filter((m) => m._creationTime > (lastReflectionTs ?? 0))
.reduce((acc, curr) => acc + curr.importance, 0);
const shouldReflect = sumOfImportanceScore > 500;
if (!shouldReflect) {
return false;
}
console.debug('sum of importance score = ', sumOfImportanceScore);
console.debug('Reflecting...');
const prompt = ['[no prose]', '[Output only JSON]', `You are ${name}, statements about you:`];
memories.forEach((m, idx) => {
prompt.push(`Statement ${idx}: ${m.description}`);
});
prompt.push('What 3 high-level insights can you infer from the above statements?');
prompt.push(
'Return in JSON format, where the key is a list of input statements that contributed to your insights and value is your insight. Make the response parseable by Typescript JSON.parse() function. DO NOT escape characters or include "\n" or white space in response.',
);
prompt.push(
'Example: [{insight: "...", statementIds: [1,2]}, {insight: "...", statementIds: [1]}, ...]',
);
const { content: reflection } = await chatCompletion({
messages: [
{
role: 'user',
content: prompt.join('\n'),
},
],
});
try {
const insights = JSON.parse(reflection) as { insight: string; statementIds: number[] }[];
const memoriesToSave = await asyncMap(insights, async (item) => {
const relatedMemoryIds = item.statementIds.map((idx: number) => memories[idx]._id);
const importance = await calculateImportance(item.insight);
const { embedding } = await fetchEmbedding(item.insight);
console.debug('adding reflection memory...', item.insight);
return {
description: item.insight,
embedding,
importance,
relatedMemoryIds,
};
});
await ctx.runMutation(selfInternal.insertReflectionMemories, {
worldId,
playerId,
reflections: memoriesToSave,
});
} catch (e) {
console.error('error saving or parsing reflection', e);
console.debug('reflection', reflection);
return false;
}
return true;
}
export const getReflectionMemories = internalQuery({
args: { worldId: v.id('worlds'), playerId, numberOfItems: v.number() },
handler: async (ctx, args) => {
const world = await ctx.db.get(args.worldId);
if (!world) {
throw new Error(`World ${args.worldId} not found`);
}
const player = world.players.find((p) => p.id === args.playerId);
if (!player) {
throw new Error(`Player ${args.playerId} not found`);
}
const playerDescription = await ctx.db
.query('playerDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId))
.first();
if (!playerDescription) {
throw new Error(`Player description for ${args.playerId} not found`);
}
const memories = await ctx.db
.query('memories')
.withIndex('playerId', (q) => q.eq('playerId', player.id))
.order('desc')
.take(args.numberOfItems);
const lastReflection = await ctx.db
.query('memories')
.withIndex('playerId_type', (q) =>
q.eq('playerId', args.playerId).eq('data.type', 'reflection'),
)
.order('desc')
.first();
return {
name: playerDescription.name,
memories,
lastReflectionTs: lastReflection?._creationTime,
};
},
});
export async function latestMemoryOfType<T extends MemoryType>(
db: DatabaseReader,
playerId: GameId<'players'>,
type: T,
) {
const entry = await db
.query('memories')
.withIndex('playerId_type', (q) => q.eq('playerId', playerId).eq('data.type', type))
.order('desc')
.first();
if (!entry) return null;
return entry as MemoryOfType<T>;
}
================================================
FILE: convex/agent/schema.ts
================================================
import { v } from 'convex/values';
import { playerId, conversationId } from '../aiTown/ids';
import { defineTable } from 'convex/server';
import { EMBEDDING_DIMENSION } from '../util/llm';
export const memoryFields = {
playerId,
description: v.string(),
embeddingId: v.id('memoryEmbeddings'),
importance: v.number(),
lastAccess: v.number(),
data: v.union(
// Setting up dynamics between players
v.object({
type: v.literal('relationship'),
// The player this memory is about, from the perspective of the player
// whose memory this is.
playerId,
}),
v.object({
type: v.literal('conversation'),
conversationId,
// The other player(s) in the conversation.
playerIds: v.array(playerId),
}),
v.object({
type: v.literal('reflection'),
relatedMemoryIds: v.array(v.id('memories')),
}),
),
};
export const memoryTables = {
memories: defineTable(memoryFields)
.index('embeddingId', ['embeddingId'])
.index('playerId_type', ['playerId', 'data.type'])
.index('playerId', ['playerId']),
memoryEmbeddings: defineTable({
playerId,
embedding: v.array(v.float64()),
}).vectorIndex('embedding', {
vectorField: 'embedding',
filterFields: ['playerId'],
dimensions: EMBEDDING_DIMENSION,
}),
};
export const agentTables = {
...memoryTables,
embeddingsCache: defineTable({
textHash: v.bytes(),
embedding: v.array(v.float64()),
}).index('text', ['textHash']),
};
================================================
FILE: convex/aiTown/agent.ts
================================================
import { ObjectType, v } from 'convex/values';
import { GameId, parseGameId } from './ids';
import { agentId, conversationId, playerId } from './ids';
import { serializedPlayer } from './player';
import { Game } from './game';
import {
ACTION_TIMEOUT,
AWKWARD_CONVERSATION_TIMEOUT,
CONVERSATION_COOLDOWN,
CONVERSATION_DISTANCE,
INVITE_ACCEPT_PROBABILITY,
INVITE_TIMEOUT,
MAX_CONVERSATION_DURATION,
MAX_CONVERSATION_MESSAGES,
MESSAGE_COOLDOWN,
MIDPOINT_THRESHOLD,
PLAYER_CONVERSATION_COOLDOWN,
} from '../constants';
import { FunctionArgs } from 'convex/server';
import { MutationCtx, internalMutation, internalQuery } from '../_generated/server';
import { distance } from '../util/geometry';
import { internal } from '../_generated/api';
import { movePlayer } from './movement';
import { insertInput } from './insertInput';
export class Agent {
id: GameId<'agents'>;
playerId: GameId<'players'>;
toRemember?: GameId<'conversations'>;
lastConversation?: number;
lastInviteAttempt?: number;
inProgressOperation?: {
name: string;
operationId: string;
started: number;
};
constructor(serialized: SerializedAgent) {
const { id, lastConversation, lastInviteAttempt, inProgressOperation } = serialized;
const playerId = parseGameId('players', serialized.playerId);
this.id = parseGameId('agents', id);
this.playerId = playerId;
this.toRemember =
serialized.toRemember !== undefined
? parseGameId('conversations', serialized.toRemember)
: undefined;
this.lastConversation = lastConversation;
this.lastInviteAttempt = lastInviteAttempt;
this.inProgressOperation = inProgressOperation;
}
tick(game: Game, now: number) {
const player = game.world.players.get(this.playerId);
if (!player) {
throw new Error(`Invalid player ID ${this.playerId}`);
}
if (this.inProgressOperation) {
if (now < this.inProgressOperation.started + ACTION_TIMEOUT) {
// Wait on the operation to finish.
return;
}
console.log(`Timing out ${JSON.stringify(this.inProgressOperation)}`);
delete this.inProgressOperation;
}
const conversation = game.world.playerConversation(player);
const member = conversation?.participants.get(player.id);
const recentlyAttemptedInvite =
this.lastInviteAttempt && now < this.lastInviteAttempt + CONVERSATION_COOLDOWN;
const doingActivity = player.activity && player.activity.until > now;
if (doingActivity && (conversation || player.pathfinding)) {
player.activity!.until = now;
}
// If we're not in a conversation, do something.
// If we aren't doing an activity or moving, do something.
// If we have been wandering but haven't thought about something to do for
// a while, do something.
if (!conversation && !doingActivity && (!player.pathfinding || !recentlyAttemptedInvite)) {
this.startOperation(game, now, 'agentDoSomething', {
worldId: game.worldId,
player: player.serialize(),
otherFreePlayers: [...game.world.players.values()]
.filter((p) => p.id !== player.id)
.filter(
(p) => ![...game.world.conversations.values()].find((c) => c.participants.has(p.id)),
)
.map((p) => p.serialize()),
agent: this.serialize(),
map: game.worldMap.serialize(),
});
return;
}
// Check to see if we have a conversation we need to remember.
if (this.toRemember) {
// Fire off the action to remember the conversation.
console.log(`Agent ${this.id} remembering conversation ${this.toRemember}`);
this.startOperation(game, now, 'agentRememberConversation', {
worldId: game.worldId,
playerId: this.playerId,
agentId: this.id,
conversationId: this.toRemember,
});
delete this.toRemember;
return;
}
if (conversation && member) {
const [otherPlayerId, otherMember] = [...conversation.participants.entries()].find(
([id]) => id !== player.id,
)!;
const otherPlayer = game.world.players.get(otherPlayerId)!;
if (member.status.kind === 'invited') {
// Accept a conversation with another agent with some probability and with
// a human unconditionally.
if (otherPlayer.human || Math.random() < INVITE_ACCEPT_PROBABILITY) {
console.log(`Agent ${player.id} accepting invite from ${otherPlayer.id}`);
conversation.acceptInvite(game, player);
// Stop moving so we can start walking towards the other player.
if (player.pathfinding) {
delete player.pathfinding;
}
} else {
console.log(`Agent ${player.id} rejecting invite from ${otherPlayer.id}`);
conversation.rejectInvite(game, now, player);
}
return;
}
if (member.status.kind === 'walkingOver') {
// Leave a conversation if we've been waiting for too long.
if (member.invited + INVITE_TIMEOUT < now) {
console.log(`Giving up on invite to ${otherPlayer.id}`);
conversation.leave(game, now, player);
return;
}
// Don't keep moving around if we're near enough.
const playerDistance = distance(player.position, otherPlayer.position);
if (playerDistance < CONVERSATION_DISTANCE) {
return;
}
// Keep moving towards the other player.
// If we're close enough to the player, just walk to them directly.
if (!player.pathfinding) {
let destination;
if (playerDistance < MIDPOINT_THRESHOLD) {
destination = {
x: Math.floor(otherPlayer.position.x),
y: Math.floor(otherPlayer.position.y),
};
} else {
destination = {
x: Math.floor((player.position.x + otherPlayer.position.x) / 2),
y: Math.floor((player.position.y + otherPlayer.position.y) / 2),
};
}
console.log(`Agent ${player.id} walking towards ${otherPlayer.id}...`, destination);
movePlayer(game, now, player, destination);
}
return;
}
if (member.status.kind === 'participating') {
const started = member.status.started;
if (conversation.isTyping && conversation.isTyping.playerId !== player.id) {
// Wait for the other player to finish typing.
return;
}
if (!conversation.lastMessage) {
const isInitiator = conversation.creator === player.id;
const awkwardDeadline = started + AWKWARD_CONVERSATION_TIMEOUT;
// Send the first message if we're the initiator or if we've been waiting for too long.
if (isInitiator || awkwardDeadline < now) {
// Grab the lock on the conversation and send a "start" message.
console.log(`${player.id} initiating conversation with ${otherPlayer.id}.`);
const messageUuid = crypto.randomUUID();
conversation.setIsTyping(now, player, messageUuid);
this.startOperation(game, now, 'agentGenerateMessage', {
worldId: game.worldId,
playerId: player.id,
agentId: this.id,
conversationId: conversation.id,
otherPlayerId: otherPlayer.id,
messageUuid,
type: 'start',
});
return;
} else {
// Wait on the other player to say something up to the awkward deadline.
return;
}
}
// See if the conversation has been going on too long and decide to leave.
const tooLongDeadline = started + MAX_CONVERSATION_DURATION;
if (tooLongDeadline < now || conversation.numMessages > MAX_CONVERSATION_MESSAGES) {
console.log(`${player.id} leaving conversation with ${otherPlayer.id}.`);
const messageUuid = crypto.randomUUID();
conversation.setIsTyping(now, player, messageUuid);
this.startOperation(game, now, 'agentGenerateMessage', {
worldId: game.worldId,
playerId: player.id,
agentId: this.id,
conversationId: conversation.id,
otherPlayerId: otherPlayer.id,
messageUuid,
type: 'leave',
});
return;
}
// Wait for the awkward deadline if we sent the last message.
if (conversation.lastMessage.author === player.id) {
const awkwardDeadline = conversation.lastMessage.timestamp + AWKWARD_CONVERSATION_TIMEOUT;
if (now < awkwardDeadline) {
return;
}
}
// Wait for a cooldown after the last message to simulate "reading" the message.
const messageCooldown = conversation.lastMessage.timestamp + MESSAGE_COOLDOWN;
if (now < messageCooldown) {
return;
}
// Grab the lock and send a message!
console.log(`${player.id} continuing conversation with ${otherPlayer.id}.`);
const messageUuid = crypto.randomUUID();
conversation.setIsTyping(now, player, messageUuid);
this.startOperation(game, now, 'agentGenerateMessage', {
worldId: game.worldId,
playerId: player.id,
agentId: this.id,
conversationId: conversation.id,
otherPlayerId: otherPlayer.id,
messageUuid,
type: 'continue',
});
return;
}
}
}
startOperation<Name extends keyof AgentOperations>(
game: Game,
now: number,
name: Name,
args: Omit<FunctionArgs<AgentOperations[Name]>, 'operationId'>,
) {
if (this.inProgressOperation) {
throw new Error(
`Agent ${this.id} already has an operation: ${JSON.stringify(this.inProgressOperation)}`,
);
}
const operationId = game.allocId('operations');
console.log(`Agent ${this.id} starting operation ${name} (${operationId})`);
game.scheduleOperation(name, { operationId, ...args } as any);
this.inProgressOperation = {
name,
operationId,
started: now,
};
}
serialize(): SerializedAgent {
return {
id: this.id,
playerId: this.playerId,
toRemember: this.toRemember,
lastConversation: this.lastConversation,
lastInviteAttempt: this.lastInviteAttempt,
inProgressOperation: this.inProgressOperation,
};
}
}
export const serializedAgent = {
id: agentId,
playerId: playerId,
toRemember: v.optional(conversationId),
lastConversation: v.optional(v.number()),
lastInviteAttempt: v.optional(v.number()),
inProgressOperation: v.optional(
v.object({
name: v.string(),
operationId: v.string(),
started: v.number(),
}),
),
};
export type SerializedAgent = ObjectType<typeof serializedAgent>;
type AgentOperations = typeof internal.aiTown.agentOperations;
export async function runAgentOperation(ctx: MutationCtx, operation: string, args: any) {
let reference;
switch (operation) {
case 'agentRememberConversation':
reference = internal.aiTown.agentOperations.agentRememberConversation;
break;
case 'agentGenerateMessage':
reference = internal.aiTown.agentOperations.agentGenerateMessage;
break;
case 'agentDoSomething':
reference = internal.aiTown.agentOperations.agentDoSomething;
break;
default:
throw new Error(`Unknown operation: ${operation}`);
}
await ctx.scheduler.runAfter(0, reference, args);
}
export const agentSendMessage = internalMutation({
args: {
worldId: v.id('worlds'),
conversationId,
agentId,
playerId,
text: v.string(),
messageUuid: v.string(),
leaveConversation: v.boolean(),
operationId: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.insert('messages', {
conversationId: args.conversationId,
author: args.playerId,
text: args.text,
messageUuid: args.messageUuid,
worldId: args.worldId,
});
await insertInput(ctx, args.worldId, 'agentFinishSendingMessage', {
conversationId: args.conversationId,
agentId: args.agentId,
timestamp: Date.now(),
leaveConversation: args.leaveConversation,
operationId: args.operationId,
});
},
});
export const findConversationCandidate = internalQuery({
args: {
now: v.number(),
worldId: v.id('worlds'),
player: v.object(serializedPlayer),
otherFreePlayers: v.array(v.object(serializedPlayer)),
},
handler: async (ctx, { now, worldId, player, otherFreePlayers }) => {
const { position } = player;
const candidates = [];
for (const otherPlayer of otherFreePlayers) {
// Find the latest conversation we're both members of.
const lastMember = await ctx.db
.query('participatedTogether')
.withIndex('edge', (q) =>
q.eq('worldId', worldId).eq('player1', player.id).eq('player2', otherPlayer.id),
)
.order('desc')
.first();
if (lastMember) {
if (now < lastMember.ended + PLAYER_CONVERSATION_COOLDOWN) {
continue;
}
}
candidates.push({ id: otherPlayer.id, position });
}
// Sort by distance and take the nearest candidate.
candidates.sort((a, b) => distance(a.position, position) - distance(b.position, position));
return candidates[0]?.id;
},
});
================================================
FILE: convex/aiTown/agentDescription.ts
================================================
import { ObjectType, v } from 'convex/values';
import { GameId, agentId, parseGameId } from './ids';
export class AgentDescription {
agentId: GameId<'agents'>;
identity: string;
plan: string;
constructor(serialized: SerializedAgentDescription) {
const { agentId, identity, plan } = serialized;
this.agentId = parseGameId('agents', agentId);
this.identity = identity;
this.plan = plan;
}
serialize(): SerializedAgentDescription {
const { agentId, identity, plan } = this;
return { agentId, identity, plan };
}
}
export const serializedAgentDescription = {
agentId,
identity: v.string(),
plan: v.string(),
};
export type SerializedAgentDescription = ObjectType<typeof serializedAgentDescription>;
================================================
FILE: convex/aiTown/agentInputs.ts
================================================
import { v } from 'convex/values';
import { agentId, conversationId, parseGameId } from './ids';
import { Player, activity } from './player';
import { Conversation, conversationInputs } from './conversation';
import { movePlayer } from './movement';
import { inputHandler } from './inputHandler';
import { point } from '../util/types';
import { Descriptions } from '../../data/characters';
import { AgentDescription } from './agentDescription';
import { Agent } from './agent';
export const agentInputs = {
finishRememberConversation: inputHandler({
args: {
operationId: v.string(),
agentId,
},
handler: (game, now, args) => {
const agentId = parseGameId('agents', args.agentId);
const agent = game.world.agents.get(agentId);
if (!agent) {
throw new Error(`Couldn't find agent: ${agentId}`);
}
if (
!agent.inProgressOperation ||
agent.inProgressOperation.operationId !== args.operationId
) {
console.debug(`Agent ${agentId} isn't remembering ${args.operationId}`);
} else {
delete agent.inProgressOperation;
delete agent.toRemember;
}
return null;
},
}),
finishDoSomething: inputHandler({
args: {
operationId: v.string(),
agentId: v.id('agents'),
destination: v.optional(point),
invitee: v.optional(v.id('players')),
activity: v.optional(activity),
},
handler: (game, now, args) => {
const agentId = parseGameId('agents', args.agentId);
const agent = game.world.agents.get(agentId);
if (!agent) {
throw new Error(`Couldn't find agent: ${agentId}`);
}
if (
!agent.inProgressOperation ||
agent.inProgressOperation.operationId !== args.operationId
) {
console.debug(`Agent ${agentId} didn't have ${args.operationId} in progress`);
return null;
}
delete agent.inProgressOperation;
const player = game.world.players.get(agent.playerId)!;
if (args.invitee) {
const inviteeId = parseGameId('players', args.invitee);
const invitee = game.world.players.get(inviteeId);
if (!invitee) {
throw new Error(`Couldn't find player: ${inviteeId}`);
}
Conversation.start(game, now, player, invitee);
agent.lastInviteAttempt = now;
}
if (args.destination) {
movePlayer(game, now, player, args.destination);
}
if (args.activity) {
player.activity = args.activity;
}
return null;
},
}),
agentFinishSendingMessage: inputHandler({
args: {
agentId,
conversationId,
timestamp: v.number(),
operationId: v.string(),
leaveConversation: v.boolean(),
},
handler: (game, now, args) => {
const agentId = parseGameId('agents', args.agentId);
const agent = game.world.agents.get(agentId);
if (!agent) {
throw new Error(`Couldn't find agent: ${agentId}`);
}
const player = game.world.players.get(agent.playerId);
if (!player) {
throw new Error(`Couldn't find player: ${agent.playerId}`);
}
const conversationId = parseGameId('conversations', args.conversationId);
const conversation = game.world.conversations.get(conversationId);
if (!conversation) {
throw new Error(`Couldn't find conversation: ${conversationId}`);
}
if (
!agent.inProgressOperation ||
agent.inProgressOperation.operationId !== args.operationId
) {
console.debug(`Agent ${agentId} wasn't sending a message ${args.operationId}`);
return null;
}
delete agent.inProgressOperation;
conversationInputs.finishSendingMessage.handler(game, now, {
playerId: agent.playerId,
conversationId: args.conversationId,
timestamp: args.timestamp,
});
if (args.leaveConversation) {
conversation.leave(game, now, player);
}
return null;
},
}),
createAgent: inputHandler({
args: {
descriptionIndex: v.number(),
},
handler: (game, now, args) => {
const description = Descriptions[args.descriptionIndex];
const playerId = Player.join(
game,
now,
description.name,
description.character,
description.identity,
);
const agentId = game.allocId('agents');
game.world.agents.set(
agentId,
new Agent({
id: agentId,
playerId: playerId,
inProgressOperation: undefined,
lastConversation: undefined,
lastInviteAttempt: undefined,
toRemember: undefined,
}),
);
game.agentDescriptions.set(
agentId,
new AgentDescription({
agentId: agentId,
identity: description.identity,
plan: description.plan,
}),
);
return { agentId };
},
}),
};
================================================
FILE: convex/aiTown/agentOperations.ts
================================================
import { v } from 'convex/values';
import { internalAction } from '../_generated/server';
import { WorldMap, serializedWorldMap } from './worldMap';
import { rememberConversation } from '../agent/memory';
import { GameId, agentId, conversationId, playerId } from './ids';
import {
continueConversationMessage,
leaveConversationMessage,
startConversationMessage,
} from '../agent/conversation';
import { assertNever } from '../util/assertNever';
import { serializedAgent } from './agent';
import { ACTIVITIES, ACTIVITY_COOLDOWN, CONVERSATION_COOLDOWN } from '../constants';
import { api, internal } from '../_generated/api';
import { sleep } from '../util/sleep';
import { serializedPlayer } from './player';
export const agentRememberConversation = internalAction({
args: {
worldId: v.id('worlds'),
playerId,
agentId,
conversationId,
operationId: v.string(),
},
handler: async (ctx, args) => {
await rememberConversation(
ctx,
args.worldId,
args.agentId as GameId<'agents'>,
args.playerId as GameId<'players'>,
args.conversationId as GameId<'conversations'>,
);
await sleep(Math.random() * 1000);
await ctx.runMutation(api.aiTown.main.sendInput, {
worldId: args.worldId,
name: 'finishRememberConversation',
args: {
agentId: args.agentId,
operationId: args.operationId,
},
});
},
});
export const agentGenerateMessage = internalAction({
args: {
worldId: v.id('worlds'),
playerId,
agentId,
conversationId,
otherPlayerId: playerId,
operationId: v.string(),
type: v.union(v.literal('start'), v.literal('continue'), v.literal('leave')),
messageUuid: v.string(),
},
handler: async (ctx, args) => {
let completionFn;
switch (args.type) {
case 'start':
completionFn = startConversationMessage;
break;
case 'continue':
completionFn = continueConversationMessage;
break;
case 'leave':
completionFn = leaveConversationMessage;
break;
default:
assertNever(args.type);
}
const text = await completionFn(
ctx,
args.worldId,
args.conversationId as GameId<'conversations'>,
args.playerId as GameId<'players'>,
args.otherPlayerId as GameId<'players'>,
);
await ctx.runMutation(internal.aiTown.agent.agentSendMessage, {
worldId: args.worldId,
conversationId: args.conversationId,
agentId: args.agentId,
playerId: args.playerId,
text,
messageUuid: args.messageUuid,
leaveConversation: args.type === 'leave',
operationId: args.operationId,
});
},
});
export const agentDoSomething = internalAction({
args: {
worldId: v.id('worlds'),
player: v.object(serializedPlayer),
agent: v.object(serializedAgent),
map: v.object(serializedWorldMap),
otherFreePlayers: v.array(v.object(serializedPlayer)),
operationId: v.string(),
},
handler: async (ctx, args) => {
const { player, agent } = args;
const map = new WorldMap(args.map);
const now = Date.now();
// Don't try to start a new conversation if we were just in one.
const justLeftConversation =
agent.lastConversation && now < agent.lastConversation + CONVERSATION_COOLDOWN;
// Don't try again if we recently tried to find someone to invite.
const recentlyAttemptedInvite =
agent.lastInviteAttempt && now < agent.lastInviteAttempt + CONVERSATION_COOLDOWN;
const recentActivity = player.activity && now < player.activity.until + ACTIVITY_COOLDOWN;
// Decide whether to do an activity or wander somewhere.
if (!player.pathfinding) {
if (recentActivity || justLeftConversation) {
await sleep(Math.random() * 1000);
await ctx.runMutation(api.aiTown.main.sendInput, {
worldId: args.worldId,
name: 'finishDoSomething',
args: {
operationId: args.operationId,
agentId: agent.id,
destination: wanderDestination(map),
},
});
return;
} else {
// TODO: have LLM choose the activity & emoji
const activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
await sleep(Math.random() * 1000);
await ctx.runMutation(api.aiTown.main.sendInput, {
worldId: args.worldId,
name: 'finishDoSomething',
args: {
operationId: args.operationId,
agentId: agent.id,
activity: {
description: activity.description,
emoji: activity.emoji,
until: Date.now() + activity.duration,
},
},
});
return;
}
}
const invitee =
justLeftConversation || recentlyAttemptedInvite
? undefined
: await ctx.runQuery(internal.aiTown.agent.findConversationCandidate, {
now,
worldId: args.worldId,
player: args.player,
otherFreePlayers: args.otherFreePlayers,
});
// TODO: We hit a lot of OCC errors on sending inputs in this file. It's
// easy for them to get scheduled at the same time and line up in time.
await sleep(Math.random() * 1000);
await ctx.runMutation(api.aiTown.main.sendInput, {
worldId: args.worldId,
name: 'finishDoSomething',
args: {
operationId: args.operationId,
agentId: args.agent.id,
invitee,
},
});
},
});
function wanderDestination(worldMap: WorldMap) {
// Wander someonewhere at least one tile away from the edge.
return {
x: 1 + Math.floor(Math.random() * (worldMap.width - 2)),
y: 1 + Math.floor(Math.random() * (worldMap.height - 2)),
};
}
================================================
FILE: convex/aiTown/conversation.ts
================================================
import { ObjectType, v } from 'convex/values';
import { GameId, parseGameId } from './ids';
import { conversationId, playerId } from './ids';
import { Player } from './player';
import { inputHandler } from './inputHandler';
import { TYPING_TIMEOUT, CONVERSATION_DISTANCE } from '../constants';
import { distance, normalize, vector } from '../util/geometry';
import { Point } from '../util/types';
import { Game } from './game';
import { stopPlayer, blocked, movePlayer } from './movement';
import { ConversationMembership, serializedConversationMembership } from './conversationMembership';
import { parseMap, serializeMap } from '../util/object';
export class Conversation {
id: GameId<'conversations'>;
creator: GameId<'players'>;
created: number;
isTyping?: {
playerId: GameId<'players'>;
messageUuid: string;
since: number;
};
lastMessage?: {
author: GameId<'players'>;
timestamp: number;
};
numMessages: number;
participants: Map<GameId<'players'>, ConversationMembership>;
constructor(serialized: SerializedConversation) {
const { id, creator, created, isTyping, lastMessage, numMessages, participants } = serialized;
this.id = parseGameId('conversations', id);
this.creator = parseGameId('players', creator);
this.created = created;
this.isTyping = isTyping && {
playerId: parseGameId('players', isTyping.playerId),
messageUuid: isTyping.messageUuid,
since: isTyping.since,
};
this.lastMessage = lastMessage && {
author: parseGameId('players', lastMessage.author),
timestamp: lastMessage.timestamp,
};
this.numMessages = numMessages;
this.participants = parseMap(participants, ConversationMembership, (m) => m.playerId);
}
tick(game: Game, now: number) {
if (this.isTyping && this.isTyping.since + TYPING_TIMEOUT < now) {
delete this.isTyping;
}
if (this.participants.size !== 2) {
console.warn(`Conversation ${this.id} has ${this.participants.size} participants`);
return;
}
const [playerId1, playerId2] = [...this.participants.keys()];
const member1 = this.participants.get(playerId1)!;
const member2 = this.participants.get(playerId2)!;
const player1 = game.world.players.get(playerId1)!;
const player2 = game.world.players.get(playerId2)!;
const playerDistance = distance(player1?.position, player2?.position);
// If the players are both in the "walkingOver" state and they're sufficiently close, transition both
// of them to "participating" and stop their paths.
if (member1.status.kind === 'walkingOver' && member2.status.kind === 'walkingOver') {
if (playerDistance < CONVERSATION_DISTANCE) {
console.log(`Starting conversation between ${player1.id} and ${player2.id}`);
// First, stop the two players from moving.
stopPlayer(player1);
stopPlayer(player2);
member1.status = { kind: 'participating', started: now };
member2.status = { kind: 'participating', started: now };
// Try to move the first player to grid point nearest the other player.
const neighbors = (p: Point) => [
{ x: p.x + 1, y: p.y },
{ x: p.x - 1, y: p.y },
{ x: p.x, y: p.y + 1 },
{ x: p.x, y: p.y - 1 },
];
const floorPos1 = { x: Math.floor(player1.position.x), y: Math.floor(player1.position.y) };
const p1Candidates = neighbors(floorPos1).filter((p) => !blocked(game, now, p, player1.id));
p1Candidates.sort((a, b) => distance(a, player2.position) - distance(b, player2.position));
if (p1Candidates.length > 0) {
const p1Candidate = p1Candidates[0];
// Try to move the second player to the grid point nearest the first player's
// destination.
const p2Candidates = neighbors(p1Candidate).filter(
(p) => !blocked(game, now, p, player2.id),
);
p2Candidates.sort(
(a, b) => distance(a, player2.position) - distance(b, player2.position),
);
if (p2Candidates.length > 0) {
const p2Candidate = p2Candidates[0];
movePlayer(game, now, player1, p1Candidate, true);
movePlayer(game, now, player2, p2Candidate, true);
}
}
}
}
// Orient the two players towards each other if they're not moving.
if (member1.status.kind === 'participating' && member2.status.kind === 'participating') {
const v = normalize(vector(player1.position, player2.position));
if (!player1.pathfinding && v) {
player1.facing = v;
}
if (!player2.pathfinding && v) {
player2.facing.dx = -v.dx;
player2.facing.dy = -v.dy;
}
}
}
static start(game: Game, now: number, player: Player, invitee: Player) {
if (player.id === invitee.id) {
throw new Error(`Can't invite yourself to a conversation`);
}
// Ensure the players still exist.
if ([...game.world.conversations.values()].find((c) => c.participants.has(player.id))) {
const reason = `Player ${player.id} is already in a conversation`;
console.log(reason);
return { error: reason };
}
if ([...game.world.conversations.values()].find((c) => c.participants.has(invitee.id))) {
const reason = `Player ${player.id} is already in a conversation`;
console.log(reason);
return { error: reason };
}
const conversationId = game.allocId('conversations');
console.log(`Creating conversation ${conversationId}`);
game.world.conversations.set(
conversationId,
new Conversation({
id: conversationId,
created: now,
creator: player.id,
numMessages: 0,
participants: [
{ playerId: player.id, invited: now, status: { kind: 'walkingOver' } },
{ playerId: invitee.id, invited: now, status: { kind: 'invited' } },
],
}),
);
return { conversationId };
}
setIsTyping(now: number, player: Player, messageUuid: string) {
if (this.isTyping) {
if (this.isTyping.playerId !== player.id) {
throw new Error(`Player ${this.isTyping.playerId} is already typing in ${this.id}`);
}
return;
}
this.isTyping = { playerId: player.id, messageUuid, since: now };
}
acceptInvite(game: Game, player: Player) {
const member = this.participants.get(player.id);
if (!member) {
throw new Error(`Player ${player.id} not in conversation ${this.id}`);
}
if (member.status.kind !== 'invited') {
throw new Error(
`Invalid membership status for ${player.id}:${this.id}: ${JSON.stringify(member)}`,
);
}
member.status = { kind: 'walkingOver' };
}
rejectInvite(game: Game, now: number, player: Player) {
const member = this.participants.get(player.id);
if (!member) {
throw new Error(`Player ${player.id} not in conversation ${this.id}`);
}
if (member.status.kind !== 'invited') {
throw new Error(
`Rejecting invite in wrong membership state: ${this.id}:${player.id}: ${JSON.stringify(
member,
)}`,
);
}
this.stop(game, now);
}
stop(game: Game, now: number) {
delete this.isTyping;
for (const [playerId, member] of this.participants.entries()) {
const agent = [...game.world.agents.values()].find((a) => a.playerId === playerId);
if (agent) {
agent.lastConversation = now;
agent.toRemember = this.id;
}
}
game.world.conversations.delete(this.id);
}
leave(game: Game, now: number, player: Player) {
const member = this.participants.get(player.id);
if (!member) {
throw new Error(`Couldn't find membership for ${this.id}:${player.id}`);
}
this.stop(game, now);
}
serialize(): SerializedConversation {
const { id, creator, created, isTyping, lastMessage, numMessages } = this;
return {
id,
creator,
created,
isTyping,
lastMessage,
numMessages,
participants: serializeMap(this.participants),
};
}
}
export const serializedConversation = {
id: conversationId,
creator: playerId,
created: v.number(),
isTyping: v.optional(
v.object({
playerId,
messageUuid: v.string(),
since: v.number(),
}),
),
lastMessage: v.optional(
v.object({
author: playerId,
timestamp: v.number(),
}),
),
numMessages: v.number(),
participants: v.array(v.object(serializedConversationMembership)),
};
export type SerializedConversation = ObjectType<typeof serializedConversation>;
export const conversationInputs = {
// Start a conversation, inviting the specified player.
// Conversations can only have two participants for now,
// so we don't have a separate "invite" input.
startConversation: inputHandler({
args: {
playerId,
invitee: playerId,
},
handler: (game: Game, now: number, args): GameId<'conversations'> => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID: ${playerId}`);
}
const inviteeId = parseGameId('players', args.invitee);
const invitee = game.world.players.get(inviteeId);
if (!invitee) {
throw new Error(`Invalid player ID: ${inviteeId}`);
}
console.log(`Starting ${playerId} ${inviteeId}...`);
const { conversationId, error } = Conversation.start(game, now, player, invitee);
if (!conversationId) {
// TODO: pass it back to the client for them to show an error.
throw new Error(error);
}
return conversationId;
},
}),
startTyping: inputHandler({
args: {
playerId,
conversationId,
messageUuid: v.string(),
},
handler: (game: Game, now: number, args): null => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID: ${playerId}`);
}
const conversationId = parseGameId('conversations', args.conversationId);
const conversation = game.world.conversations.get(conversationId);
if (!conversation) {
throw new Error(`Invalid conversation ID: ${conversationId}`);
}
if (conversation.isTyping && conversation.isTyping.playerId !== playerId) {
throw new Error(
`Player ${conversation.isTyping.playerId} is already typing in ${conversationId}`,
);
}
conversation.isTyping = { playerId, messageUuid: args.messageUuid, since: now };
return null;
},
}),
finishSendingMessage: inputHandler({
args: {
playerId,
conversationId,
timestamp: v.number(),
},
handler: (game: Game, now: number, args): null => {
const playerId = parseGameId('players', args.playerId);
const conversationId = parseGameId('conversations', args.conversationId);
const conversation = game.world.conversations.get(conversationId);
if (!conversation) {
throw new Error(`Invalid conversation ID: ${conversationId}`);
}
if (conversation.isTyping && conversation.isTyping.playerId === playerId) {
delete conversation.isTyping;
}
conversation.lastMessage = { author: playerId, timestamp: args.timestamp };
conversation.numMessages++;
return null;
},
}),
// Accept an invite to a conversation, which puts the
// player in the "walkingOver" state until they're close
// enough to the other participant.
acceptInvite: inputHandler({
args: {
playerId,
conversationId,
},
handler: (game: Game, now: number, args): null => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID ${playerId}`);
}
const conversationId = parseGameId('conversations', args.conversationId);
const conversation = game.world.conversations.get(conversationId);
if (!conversation) {
throw new Error(`Invalid conversation ID ${conversationId}`);
}
conversation.acceptInvite(game, player);
return null;
},
}),
// Reject the invite. Eventually we might add a message
// that explains why!
rejectInvite: inputHandler({
args: {
playerId,
conversationId,
},
handler: (game: Game, now: number, args): null => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID ${playerId}`);
}
const conversationId = parseGameId('conversations', args.conversationId);
const conversation = game.world.conversations.get(conversationId);
if (!conversation) {
throw new Error(`Invalid conversation ID ${conversationId}`);
}
conversation.rejectInvite(game, now, player);
return null;
},
}),
// Leave a conversation.
leaveConversation: inputHandler({
args: {
playerId,
conversationId,
},
handler: (game: Game, now: number, args): null => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID ${playerId}`);
}
const conversationId = parseGameId('conversations', args.conversationId);
const conversation = game.world.conversations.get(conversationId);
if (!conversation) {
throw new Error(`Invalid conversation ID ${conversationId}`);
}
conversation.leave(game, now, player);
return null;
},
}),
};
================================================
FILE: convex/aiTown/conversationMembership.ts
================================================
import { ObjectType, v } from 'convex/values';
import { GameId, parseGameId, playerId } from './ids';
export const serializedConversationMembership = {
playerId,
invited: v.number(),
status: v.union(
v.object({ kind: v.literal('invited') }),
v.object({ kind: v.literal('walkingOver') }),
v.object({ kind: v.literal('participating'), started: v.number() }),
),
};
export type SerializedConversationMembership = ObjectType<typeof serializedConversationMembership>;
export class ConversationMembership {
playerId: GameId<'players'>;
invited: number;
status:
| { kind: 'invited' }
| { kind: 'walkingOver' }
| { kind: 'participating'; started: number };
constructor(serialized: SerializedConversationMembership) {
const { playerId, invited, status } = serialized;
this.playerId = parseGameId('players', playerId);
this.invited = invited;
this.status = status;
}
serialize(): SerializedConversationMembership {
const { playerId, invited, status } = this;
return {
playerId,
invited,
status,
};
}
}
================================================
FILE: convex/aiTown/game.ts
================================================
import { Infer, v } from 'convex/values';
import { Doc, Id } from '../_generated/dataModel';
import {
ActionCtx,
DatabaseReader,
MutationCtx,
internalMutation,
internalQuery,
} from '../_generated/server';
import { World, serializedWorld } from './world';
import { WorldMap, serializedWorldMap } from './worldMap';
import { PlayerDescription, serializedPlayerDescription } from './playerDescription';
import { Location, locationFields, playerLocation } from './location';
import { runAgentOperation } from './agent';
import { GameId, IdTypes, allocGameId } from './ids';
import { InputArgs, InputNames, inputs } from './inputs';
import {
AbstractGame,
EngineUpdate,
applyEngineUpdate,
engineUpdate,
loadEngine,
} from '../engine/abstractGame';
import { internal } from '../_generated/api';
import { HistoricalObject } from '../engine/historicalObject';
import { AgentDescription, serializedAgentDescription } from './agentDescription';
import { parseMap, serializeMap } from '../util/object';
const gameState = v.object({
world: v.object(serializedWorld),
playerDescriptions: v.array(v.object(serializedPlayerDescription)),
agentDescriptions: v.array(v.object(serializedAgentDescription)),
worldMap: v.object(serializedWorldMap),
});
type GameState = Infer<typeof gameState>;
const gameStateDiff = v.object({
world: v.object(serializedWorld),
playerDescriptions: v.optional(v.array(v.object(serializedPlayerDescription))),
agentDescriptions: v.optional(v.array(v.object(serializedAgentDescription))),
worldMap: v.optional(v.object(serializedWorldMap)),
agentOperations: v.array(v.object({ name: v.string(), args: v.any() })),
});
type GameStateDiff = Infer<typeof gameStateDiff>;
export class Game extends AbstractGame {
tickDuration = 16;
stepDuration = 1000;
maxTicksPerStep = 600;
maxInputsPerStep = 32;
world: World;
historicalLocations: Map<GameId<'players'>, HistoricalObject<Location>>;
descriptionsModified: boolean;
worldMap: WorldMap;
playerDescriptions: Map<GameId<'players'>, PlayerDescription>;
agentDescriptions: Map<GameId<'agents'>, AgentDescription>;
pendingOperations: Array<{ name: string; args: any }> = [];
numPathfinds: number;
constructor(
engine: Doc<'engines'>,
public worldId: Id<'worlds'>,
state: GameState,
) {
super(engine);
this.world = new World(state.world);
delete this.world.historicalLocations;
this.descriptionsModified = false;
this.worldMap = new WorldMap(state.worldMap);
this.agentDescriptions = parseMap(state.agentDescriptions, AgentDescription, (a) => a.agentId);
this.playerDescriptions = parseMap(
state.playerDescriptions,
PlayerDescription,
(p) => p.playerId,
);
this.historicalLocations = new Map();
this.numPathfinds = 0;
}
static async load(
db: DatabaseReader,
worldId: Id<'worlds'>,
generationNumber: number,
): Promise<{ engine: Doc<'engines'>; gameState: GameState }> {
const worldDoc = await db.get(worldId);
if (!worldDoc) {
throw new Error(`No world found with id ${worldId}`);
}
const worldStatus = await db
.query('worldStatus')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.unique();
if (!worldStatus) {
throw new Error(`No engine found for world ${worldId}`);
}
const engine = await loadEngine(db, worldStatus.engineId, generationNumber);
const playerDescriptionsDocs = await db
.query('playerDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.collect();
const agentDescriptionsDocs = await db
.query('agentDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.collect();
const worldMapDoc = await db
.query('maps')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.unique();
if (!worldMapDoc) {
throw new Error(`No map found for world ${worldId}`);
}
// Discard the system fields and historicalLocations from the world state.
const { _id, _creationTime, historicalLocations: _, ...world } = worldDoc;
const playerDescriptions = playerDescriptionsDocs
// Discard player descriptions for players that no longer exist.
.filter((d) => !!world.players.find((p) => p.id === d.playerId))
.map(({ _id, _creationTime, worldId: _, ...doc }) => doc);
const agentDescriptions = agentDescriptionsDocs
.filter((a) => !!world.agents.find((p) => p.id === a.agentId))
.map(({ _id, _creationTime, worldId: _, ...doc }) => doc);
const {
_id: _mapId,
_creationTime: _mapCreationTime,
worldId: _mapWorldId,
...worldMap
} = worldMapDoc;
return {
engine,
gameState: {
world,
playerDescriptions,
agentDescriptions,
worldMap,
},
};
}
allocId<T extends IdTypes>(idType: T): GameId<T> {
const id = allocGameId(idType, this.world.nextId);
this.world.nextId += 1;
return id;
}
scheduleOperation(name: string, args: unknown) {
this.pendingOperations.push({ name, args });
}
handleInput<Name extends InputNames>(now: number, name: Name, args: InputArgs<Name>) {
const handler = inputs[name]?.handler;
if (!handler) {
throw new Error(`Invalid input: ${name}`);
}
return handler(this, now, args as any);
}
beginStep(_now: number) {
// Store the current location of all players in the history tracking buffer.
this.historicalLocations.clear();
for (const player of this.world.players.values()) {
this.historicalLocations.set(
player.id,
new HistoricalObject(locationFields, playerLocation(player)),
);
}
this.numPathfinds = 0;
}
tick(now: number) {
for (const player of this.world.players.values()) {
player.tick(this, now);
}
for (const player of this.world.players.values()) {
player.tickPathfinding(this, now);
}
for (const player of this.world.players.values()) {
player.tickPosition(this, now);
}
for (const conversation of this.world.conversations.values()) {
conversation.tick(this, now);
}
for (const agent of this.world.agents.values()) {
agent.tick(this, now);
}
// Save each player's location into the history buffer at the end of
// each tick.
for (const player of this.world.players.values()) {
let historicalObject = this.historicalLocations.get(player.id);
if (!historicalObject) {
historicalObject = new HistoricalObject(locationFields, playerLocation(player));
this.historicalLocations.set(player.id, historicalObject);
}
historicalObject.update(now, playerLocation(player));
}
}
async saveStep(ctx: ActionCtx, engineUpdate: EngineUpdate): Promise<void> {
const diff = this.takeDiff();
await ctx.runMutation(internal.aiTown.game.saveWorld, {
engineId: this.engine._id,
engineUpdate,
worldId: this.worldId,
worldDiff: diff,
});
}
takeDiff(): GameStateDiff {
const historicalLocations = [];
let bufferSize = 0;
for (const [id, historicalObject] of this.historicalLocations.entries()) {
const buffer = historicalObject.pack();
if (!buffer) {
continue;
}
historicalLocations.push({ playerId: id, location: buffer });
bufferSize += buffer.byteLength;
}
if (bufferSize > 0) {
console.debug(
`Packed ${Object.entries(historicalLocations).length} history buffers in ${(
bufferSize / 1024
).toFixed(2)}KiB.`,
);
}
this.historicalLocations.clear();
const result: GameStateDiff = {
world: { ...this.world.serialize(), historicalLocations },
agentOperations: this.pendingOperations,
};
this.pendingOperations = [];
if (this.descriptionsModified) {
result.playerDescriptions = serializeMap(this.playerDescriptions);
result.agentDescriptions = serializeMap(this.agentDescriptions);
result.worldMap = this.worldMap.serialize();
this.descriptionsModified = false;
}
return result;
}
static async saveDiff(ctx: MutationCtx, worldId: Id<'worlds'>, diff: GameStateDiff) {
const existingWorld = await ctx.db.get(worldId);
if (!existingWorld) {
throw new Error(`No world found with id ${worldId}`);
}
const newWorld = diff.world;
// Archive newly deleted players, conversations, and agents.
for (const player of existingWorld.players) {
if (!newWorld.players.some((p) => p.id === player.id)) {
await ctx.db.insert('archivedPlayers', { worldId, ...player });
}
}
for (const conversation of existingWorld.conversations) {
if (!newWorld.conversations.some((c) => c.id === conversation.id)) {
const participants = conversation.participants.map((p) => p.playerId);
const archivedConversation = {
worldId,
id: conversation.id,
created: conversation.created,
creator: conversation.creator,
ended: Date.now(),
lastMessage: conversation.lastMessage,
numMessages: conversation.numMessages,
participants,
};
await ctx.db.insert('archivedConversations', archivedConversation);
for (let i = 0; i < participants.length; i++) {
for (let j = 0; j < participants.length; j++) {
if (i == j) {
continue;
}
const player1 = participants[i];
const player2 = participants[j];
await ctx.db.insert('participatedTogether', {
worldId,
conversationId: conversation.id,
player1,
player2,
ended: Date.now(),
});
}
}
}
}
for (const conversation of existingWorld.agents) {
if (!newWorld.agents.some((a) => a.id === conversation.id)) {
await ctx.db.insert('archivedAgents', { worldId, ...conversation });
}
}
// Update the world state.
await ctx.db.replace(worldId, newWorld);
// Update the larger description tables if they changed.
const { playerDescriptions, agentDescriptions, worldMap } = diff;
if (playerDescriptions) {
for (const description of playerDescriptions) {
const existing = await ctx.db
.query('playerDescriptions')
.withIndex('worldId', (q) =>
q.eq('worldId', worldId).eq('playerId', description.playerId),
)
.unique();
if (existing) {
await ctx.db.replace(existing._id, { worldId, ...description });
} else {
await ctx.db.insert('playerDescriptions', { worldId, ...description });
}
}
}
if (agentDescriptions) {
for (const description of agentDescriptions) {
const existing = await ctx.db
.query('agentDescriptions')
.withIndex('worldId', (q) => q.eq('worldId', worldId).eq('agentId', description.agentId))
.unique();
if (existing) {
await ctx.db.replace(existing._id, { worldId, ...description });
} else {
await ctx.db.insert('agentDescriptions', { worldId, ...description });
}
}
}
if (worldMap) {
const existing = await ctx.db
.query('maps')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.unique();
if (existing) {
await ctx.db.replace(existing._id, { worldId, ...worldMap });
} else {
await ctx.db.insert('maps', { worldId, ...worldMap });
}
}
// Start the desired agent operations.
for (const operation of diff.agentOperations) {
await runAgentOperation(ctx, operation.name, operation.args);
}
}
}
export const loadWorld = internalQuery({
args: {
worldId: v.id('worlds'),
generationNumber: v.number(),
},
handler: async (ctx, args) => {
return await Game.load(ctx.db, args.worldId, args.generationNumber);
},
});
export const saveWorld = internalMutation({
args: {
engineId: v.id('engines'),
engineUpdate,
worldId: v.id('worlds'),
worldDiff: gameStateDiff,
},
handler: async (ctx, args) => {
await applyEngineUpdate(ctx, args.engineId, args.engineUpdate);
await Game.saveDiff(ctx, args.worldId, args.worldDiff);
},
});
================================================
FILE: convex/aiTown/ids.ts
================================================
import { v } from 'convex/values';
const IdShortCodes = { agents: 'a', conversations: 'c', players: 'p', operations: 'o' };
export type IdTypes = keyof typeof IdShortCodes;
export type GameId<T extends IdTypes> = string & { __type: T };
export function parseGameId<T extends IdTypes>(idType: T, gameId: string): GameId<T> {
const type = gameId[0];
const match = Object.entries(IdShortCodes).find(([_, value]) => value === type);
if (!match || match[0] !== idType) {
throw new Error(`Invalid game ID type: ${type}`);
}
const number = parseInt(gameId.slice(2), 10);
if (isNaN(number) || !Number.isInteger(number) || number < 0) {
throw new Error(`Invalid game ID number: ${gameId}`);
}
return gameId as GameId<T>;
}
export function allocGameId<T extends IdTypes>(idType: T, idNumber: number): GameId<T> {
const type = IdShortCodes[idType];
if (!type) {
throw new Error(`Invalid game ID type: ${idType}`);
}
return `${type}:${idNumber}` as GameId<T>;
}
export const conversationId = v.string();
export const playerId = v.string();
export const agentId = v.string();
export const operationId = v.string();
================================================
FILE: convex/aiTown/inputHandler.ts
================================================
import { ObjectType, PropertyValidators, Value } from 'convex/values';
import type { Game } from './game';
export function inputHandler<ArgsValidator extends PropertyValidators, Return extends Value>(def: {
args: ArgsValidator;
handler: (game: Game, now: number, args: ObjectType<ArgsValidator>) => Return;
}) {
return def;
}
================================================
FILE: convex/aiTown/inputs.ts
================================================
import { ObjectType } from 'convex/values';
import { playerInputs } from './player';
import { conversationInputs } from './conversation';
import { agentInputs } from './agentInputs';
// It's easy to hit circular dependencies with these imports,
// so assert at module scope so we hit errors when analyzing.
if (playerInputs === undefined || conversationInputs === undefined || agentInputs === undefined) {
throw new Error("Input map is undefined, check if there's a circular import.");
}
export const inputs = {
...playerInputs,
// Inputs for the messaging layer.
...conversationInputs,
// Inputs for the agent layer.
...agentInputs,
};
export type Inputs = typeof inputs;
export type InputNames = keyof Inputs;
export type InputArgs<Name extends InputNames> = ObjectType<Inputs[Name]['args']>;
export type InputReturnValue<Name extends InputNames> = ReturnType<
Inputs[Name]['handler']
> extends Promise<infer T>
? T
: never;
================================================
FILE: convex/aiTown/insertInput.ts
================================================
import { MutationCtx } from '../_generated/server';
import { Id } from '../_generated/dataModel';
import { engineInsertInput } from '../engine/abstractGame';
import { InputNames, InputArgs } from './inputs';
export async function insertInput<Name extends InputNames>(
ctx: MutationCtx,
worldId: Id<'worlds'>,
name: Name,
args: InputArgs<Name>,
): Promise<Id<'inputs'>> {
const worldStatus = await ctx.db
.query('worldStatus')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.unique();
if (!worldStatus) {
throw new Error(`World for engine ${worldId} not found`);
}
return await engineInsertInput(ctx, worldStatus.engineId, name, args);
}
================================================
FILE: convex/aiTown/location.ts
================================================
import { FieldConfig } from '../engine/historicalObject';
import { Player } from './player';
export type Location = {
// Unpacked player position.
x: number;
y: number;
// Normalized facing vector.
dx: number;
dy: number;
speed: number;
};
export const locationFields: FieldConfig = [
{ name: 'x', precision: 8 },
{ name: 'y', precision: 8 },
{ name: 'dx', precision: 8 },
{ name: 'dy', precision: 8 },
{ name: 'speed', precision: 16 },
];
export function playerLocation(player: Player): Location {
return {
x: player.position.x,
y: player.position.y,
dx: player.facing.dx,
dy: player.facing.dy,
speed: player.speed,
};
}
================================================
FILE: convex/aiTown/main.ts
================================================
import { ConvexError, v } from 'convex/values';
import { DatabaseReader, MutationCtx, internalAction, mutation, query } from '../_generated/server';
import { insertInput } from './insertInput';
import { Game } from './game';
import { internal } from '../_generated/api';
import { sleep } from '../util/sleep';
import { Id } from '../_generated/dataModel';
import { ENGINE_ACTION_DURATION } from '../constants';
export async function createEngine(ctx: MutationCtx) {
const now = Date.now();
const engineId = await ctx.db.insert('engines', {
currentTime: now,
generationNumber: 0,
running: true,
});
return engineId;
}
async function loadWorldStatus(db: DatabaseReader, worldId: Id<'worlds'>) {
const worldStatus = await db
.query('worldStatus')
.withIndex('worldId', (q) => q.eq('worldId', worldId))
.unique();
if (!worldStatus) {
throw new Error(`No engine found for world ${worldId}`);
}
return worldStatus;
}
export async function startEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
const { engineId } = await loadWorldStatus(ctx.db, worldId);
const engine = await ctx.db.get(engineId);
if (!engine) {
throw new Error(`Invalid engine ID: ${engineId}`);
}
if (engine.running) {
throw new Error(`Engine ${engineId} isn't currently stopped`);
}
const now = Date.now();
const generationNumber = engine.generationNumber + 1;
await ctx.db.patch(engineId, {
// Forcibly advance time to the present. This does mean we'll skip
// simulating the time the engine was stopped, but we don't want
// to have to simulate a potentially large stopped window and send
// it down to clients.
lastStepTs: engine.currentTime,
currentTime: now,
running: true,
generationNumber,
});
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
worldId: worldId,
generationNumber,
maxDuration: ENGINE_ACTION_DURATION,
});
}
export async function kickEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
const { engineId } = await loadWorldStatus(ctx.db, worldId);
const engine = await ctx.db.get(engineId);
if (!engine) {
throw new Error(`Invalid engine ID: ${engineId}`);
}
if (!engine.running) {
throw new Error(`Engine ${engineId} isn't currently running`);
}
const generationNumber = engine.generationNumber + 1;
await ctx.db.patch(engineId, { generationNumber });
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
worldId: worldId,
generationNumber,
maxDuration: ENGINE_ACTION_DURATION,
});
}
export async function stopEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
const { engineId } = await loadWorldStatus(ctx.db, worldId);
const engine = await ctx.db.get(engineId);
if (!engine) {
throw new Error(`Invalid engine ID: ${engineId}`);
}
if (!engine.running) {
throw new Error(`Engine ${engineId} isn't currently running`);
}
await ctx.db.patch(engineId, { running: false });
}
export const runStep = internalAction({
args: {
worldId: v.id('worlds'),
generationNumber: v.number(),
maxDuration: v.number(),
},
handler: async (ctx, args) => {
try {
const { engine, gameState } = await ctx.runQuery(internal.aiTown.game.loadWorld, {
worldId: args.worldId,
generationNumber: args.generationNumber,
});
const game = new Game(engine, args.worldId, gameState);
let now = Date.now();
const deadline = now + args.maxDuration;
while (now < deadline) {
await game.runStep(ctx, now);
const sleepUntil = Math.min(now + game.stepDuration, deadline);
await sleep(sleepUntil - now);
now = Date.now();
}
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
worldId: args.worldId,
generationNumber: game.engine.generationNumber,
maxDuration: args.maxDuration,
});
} catch (e: unknown) {
if (e instanceof ConvexError) {
if (e.data.kind === 'engineNotRunning') {
console.debug(`Engine is not running: ${e.message}`);
return;
}
if (e.data.kind === 'generationNumber') {
console.debug(`Generation number mismatch: ${e.message}`);
return;
}
}
throw e;
}
},
});
export const sendInput = mutation({
args: {
worldId: v.id('worlds'),
name: v.string(),
args: v.any(),
},
handler: async (ctx, args) => {
return await insertInput(ctx, args.worldId, args.name as any, args.args);
},
});
export const inputStatus = query({
args: {
inputId: v.id('inputs'),
},
handler: async (ctx, args) => {
const input = await ctx.db.get(args.inputId);
if (!input) {
throw new Error(`Invalid input ID: ${args.inputId}`);
}
return input.returnValue ?? null;
},
});
================================================
FILE: convex/aiTown/movement.ts
================================================
import { movementSpeed } from '../../data/characters';
import { COLLISION_THRESHOLD } from '../constants';
import { compressPath, distance, manhattanDistance, pointsEqual } from '../util/geometry';
import { MinHeap } from '../util/minheap';
import { Point, Vector } from '../util/types';
import { Game } from './game';
import { GameId } from './ids';
import { Player } from './player';
import { WorldMap } from './worldMap';
type PathCandidate = {
position: Point;
facing?: Vector;
t: number;
length: number;
cost: number;
prev?: PathCandidate;
};
export function stopPlayer(player: Player) {
delete player.pathfinding;
player.speed = 0;
}
export function movePlayer(
game: Game,
now: number,
player: Player,
destination: Point,
allowInConversation?: boolean,
) {
if (Math.floor(destination.x) !== destination.x || Math.floor(destination.y) !== destination.y) {
throw new Error(`Non-integral destination: ${JSON.stringify(destination)}`);
}
const { position } = player;
// Close enough to current position or destination => no-op.
if (pointsEqual(position, destination)) {
return;
}
// Don't allow players in a conversation to move.
const inConversation = [...game.world.conversations.values()].some(
(c) => c.participants.get(player.id)?.status.kind === 'participating',
);
if (inConversation && !allowInConversation) {
throw new Error(`Can't move when in a conversation. Leave the conversation first!`);
}
player.pathfinding = {
destination: destination,
started: now,
state: {
kind: 'needsPath',
},
};
return;
}
export function findRoute(game: Game, now: number, player: Player, destination: Point) {
const minDistances: PathCandidate[][] = [];
const explore = (current: PathCandidate): Array<PathCandidate> => {
const { x, y } = current.position;
const neighbors = [];
// If we're not on a grid point, first try to move horizontally
// or vertically to a grid point. Note that this can create very small
// deltas between the current position and the nearest grid point so
// be careful to preserve the `facing` vectors rather than trying to
// derive them anew.
if (x !== Math.floor(x)) {
neighbors.push(
{ position: { x: Math.floor(x), y }, facing: { dx: -1, dy: 0 } },
{ position: { x: Math.floor(x) + 1, y }, facing: { dx: 1, dy: 0 } },
);
}
if (y !== Math.floor(y)) {
neighbors.push(
{ position: { x, y: Math.floor(y) }, facing: { dx: 0, dy: -1 } },
{ position: { x, y: Math.floor(y) + 1 }, facing: { dx: 0, dy: 1 } },
);
}
// Otherwise, just move to adjacent grid points.
if (x == Math.floor(x) && y == Math.floor(y)) {
neighbors.push(
{ position: { x: x + 1, y }, facing: { dx: 1, dy: 0 } },
{ position: { x: x - 1, y }, facing: { dx: -1, dy: 0 } },
{ position: { x, y: y + 1 }, facing: { dx: 0, dy: 1 } },
{ position: { x, y: y - 1 }, facing: { dx: 0, dy: -1 } },
);
}
const next = [];
for (const { position, facing } of neighbors) {
const segmentLength = distance(current.position, position);
const length = current.length + segmentLength;
if (blocked(game, now, position, player.id)) {
continue;
}
const remaining = manhattanDistance(position, destination);
const path = {
position,
facing,
// Movement speed is in tiles per second.
t: current.t + (segmentLength / movementSpeed) * 1000,
length,
cost: length + remaining,
prev: current,
};
const existingMin = minDistances[position.y]?.[position.x];
if (existingMin && existingMin.cost <= path.cost) {
continue;
}
minDistances[position.y] ??= [];
minDistances[position.y][position.x] = path;
next.push(path);
}
return next;
};
const startingLocation = player.position;
const startingPosition = { x: startingLocation.x, y: startingLocation.y };
let current: PathCandidate | undefined = {
position: startingPosition,
facing: player.facing,
t: now,
length: 0,
cost: manhattanDistance(startingPosition, destination),
prev: undefined,
};
let bestCandidate = current;
const minheap = MinHeap<PathCandidate>((p0, p1) => p0.cost > p1.cost);
while (current) {
if (pointsEqual(current.position, destination)) {
break;
}
if (
manhattanDistance(current.position, destination) <
manhattanDistance(bestCandidate.position, destination)
) {
bestCandidate = current;
}
for (const candidate of explore(current)) {
minheap.push(candidate);
}
current = minheap.pop();
}
let newDestination = null;
if (!current) {
if (bestCandidate.length === 0) {
return null;
}
current = bestCandidate;
newDestination = current.position;
}
const densePath = [];
let facing = current.facing!;
while (current) {
densePath.push({ position: current.position, t: current.t, facing });
facing = current.facing!;
current = current.prev;
}
densePath.reverse();
return { path: compressPath(densePath), newDestination };
}
export function blocked(game: Game, now: number, pos: Point, playerId?: GameId<'players'>) {
const otherPositions = [...game.world.players.values()]
.filter((p) => p.id !== playerId)
.map((p) => p.position);
return blockedWithPositions(pos, otherPositions, game.worldMap);
}
export function blockedWithPositions(position: Point, otherPositions: Point[], map: WorldMap) {
if (isNaN(position.x) || isNaN(position.y)) {
throw new Error(`NaN position in ${JSON.stringify(position)}`);
}
if (position.x < 0 || position.y < 0 || position.x >= map.width || position.y >= map.height) {
return 'out of bounds';
}
for (const layer of map.objectTiles) {
if (layer[Math.floor(position.x)][Math.floor(position.y)] !== -1) {
return 'world blocked';
}
}
for (const otherPosition of otherPositions) {
if (distance(otherPosition, position) < COLLISION_THRESHOLD) {
return 'player';
}
}
return null;
}
================================================
FILE: convex/aiTown/player.ts
================================================
import { Infer, ObjectType, v } from 'convex/values';
import { Point, Vector, path, point, vector } from '../util/types';
import { GameId, parseGameId } from './ids';
import { playerId } from './ids';
import {
PATHFINDING_TIMEOUT,
PATHFINDING_BACKOFF,
HUMAN_IDLE_TOO_LONG,
MAX_HUMAN_PLAYERS,
MAX_PATHFINDS_PER_STEP,
} from '../constants';
import { pointsEqual, pathPosition } from '../util/geometry';
import { Game } from './game';
import { stopPlayer, findRoute, blocked, movePlayer } from './movement';
import { inputHandler } from './inputHandler';
import { characters } from '../../data/characters';
import { PlayerDescription } from './playerDescription';
const pathfinding = v.object({
destination: point,
started: v.number(),
state: v.union(
v.object({
kind: v.literal('needsPath'),
}),
v.object({
kind: v.literal('waiting'),
until: v.number(),
}),
v.object({
kind: v.literal('moving'),
path,
}),
),
});
export type Pathfinding = Infer<typeof pathfinding>;
export const activity = v.object({
description: v.string(),
emoji: v.optional(v.string()),
until: v.number(),
});
export type Activity = Infer<typeof activity>;
export const serializedPlayer = {
id: playerId,
human: v.optional(v.string()),
pathfinding: v.optional(pathfinding),
activity: v.optional(activity),
// The last time they did something.
lastInput: v.number(),
position: point,
facing: vector,
speed: v.number(),
};
export type SerializedPlayer = ObjectType<typeof serializedPlayer>;
export class Player {
id: GameId<'players'>;
human?: string;
pathfinding?: Pathfinding;
activity?: Activity;
lastInput: number;
position: Point;
facing: Vector;
speed: number;
constructor(serialized: SerializedPlayer) {
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = serialized;
this.id = parseGameId('players', id);
this.human = human;
this.pathfinding = pathfinding;
this.activity = activity;
this.lastInput = lastInput;
this.position = position;
this.facing = facing;
this.speed = speed;
}
tick(game: Game, now: number) {
if (this.human && this.lastInput < now - HUMAN_IDLE_TOO_LONG) {
this.leave(game, now);
}
}
tickPathfinding(game: Game, now: number) {
// There's nothing to do if we're not moving.
const { pathfinding, position } = this;
if (!pathfinding) {
return;
}
// Stop pathfinding if we've reached our destination.
if (pathfinding.state.kind === 'moving' && pointsEqual(pathfinding.destination, position)) {
stopPlayer(this);
}
// Stop pathfinding if we've timed out.
if (pathfinding.started + PATHFINDING_TIMEOUT < now) {
console.warn(`Timing out pathfinding for ${this.id}`);
stopPlayer(this);
}
// Transition from "waiting" to "needsPath" if we're past the deadline.
if (pathfinding.state.kind === 'waiting' && pathfinding.state.until < now) {
pathfinding.state = { kind: 'needsPath' };
}
// Perform pathfinding if needed.
if (pathfinding.state.kind === 'needsPath' && game.numPathfinds < MAX_PATHFINDS_PER_STEP) {
game.numPathfinds++;
if (game.numPathfinds === MAX_PATHFINDS_PER_STEP) {
console.warn(`Reached max pathfinds for this step`);
}
const route = findRoute(game, now, this, pathfinding.destination);
if (route === null) {
console.log(`Failed to route to ${JSON.stringify(pathfinding.destination)}`);
stopPlayer(this);
} else {
if (route.newDestination) {
console.warn(
`Updating destination from ${JSON.stringify(
pathfinding.destination,
)} to ${JSON.stringify(route.newDestination)}`,
);
pathfinding.destination = route.newDestination;
}
pathfinding.state = { kind: 'moving', path: route.path };
}
}
}
tickPosition(game: Game, now: number) {
// There's nothing to do if we're not moving.
if (!this.pathfinding || this.pathfinding.state.kind !== 'moving') {
this.speed = 0;
return;
}
// Compute a candidate new position and check if it collides
// with anything.
const candidate = pathPosition(this.pathfinding.state.path as any, now);
if (!candidate) {
console.warn(`Path out of range of ${now} for ${this.id}`);
return;
}
const { position, facing, velocity } = candidate;
const collisionReason = blocked(game, now, position, this.id);
if (collisionReason !== null) {
const backoff = Math.random() * PATHFINDING_BACKOFF;
console.warn(`Stopping path for ${this.id}, waiting for ${backoff}ms: ${collisionReason}`);
this.pathfinding.state = {
kind: 'waiting',
until: now + backoff,
};
return;
}
// Update the player's location.
this.position = position;
this.facing = facing;
this.speed = velocity;
}
static join(
game: Game,
now: number,
name: string,
character: string,
description: string,
tokenIdentifier?: string,
) {
if (tokenIdentifier) {
let numHumans = 0;
for (const player of game.world.players.values()) {
if (player.human) {
numHumans++;
}
if (player.human === tokenIdentifier) {
throw new Error(`You are already in this game!`);
}
}
if (numHumans >= MAX_HUMAN_PLAYERS) {
throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`);
}
}
let position;
for (let attempt = 0; attempt < 10; attempt++) {
const candidate = {
x: Math.floor(Math.random() * game.worldMap.width),
y: Math.floor(Math.random() * game.worldMap.height),
};
if (blocked(game, now, candidate)) {
continue;
}
position = candidate;
break;
}
if (!position) {
throw new Error(`Failed to find a free position!`);
}
const facingOptions = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
];
const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)];
if (!characters.find((c) => c.name === character)) {
throw new Error(`Invalid character: ${character}`);
}
const playerId = game.allocId('players');
game.world.players.set(
playerId,
new Player({
id: playerId,
human: tokenIdentifier,
lastInput: now,
position,
facing,
speed: 0,
}),
);
game.playerDescriptions.set(
playerId,
new PlayerDescription({
playerId,
character,
description,
name,
}),
);
game.descriptionsModified = true;
return playerId;
}
leave(game: Game, now: number) {
// Stop our conversation if we're leaving the game.
const conversation = [...game.world.conversations.values()].find((c) =>
c.participants.has(this.id),
);
if (conversation) {
conversation.stop(game, now);
}
game.world.players.delete(this.id);
}
serialize(): SerializedPlayer {
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = this;
return {
id,
human,
pathfinding,
activity,
lastInput,
position,
facing,
speed,
};
}
}
export const playerInputs = {
join: inputHandler({
args: {
name: v.string(),
character: v.string(),
description: v.string(),
tokenIdentifier: v.optional(v.string()),
},
handler: (game, now, args) => {
Player.join(game, now, args.name, args.character, args.description, args.tokenIdentifier);
return null;
},
}),
leave: inputHandler({
args: { playerId },
handler: (game, now, args) => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID ${playerId}`);
}
player.leave(game, now);
return null;
},
}),
moveTo: inputHandler({
args: {
playerId,
destination: v.union(point, v.null()),
},
handler: (game, now, args) => {
const playerId = parseGameId('players', args.playerId);
const player = game.world.players.get(playerId);
if (!player) {
throw new Error(`Invalid player ID ${playerId}`);
}
if (args.destination) {
movePlayer(game, now, player, args.destination);
} else {
stopPlayer(player);
}
return null;
},
}),
};
================================================
FILE: convex/aiTown/playerDescription.ts
================================================
import { ObjectType, v } from 'convex/values';
import { GameId, parseGameId, playerId } from './ids';
export const serializedPlayerDescription = {
playerId,
name: v.string(),
description: v.string(),
character: v.string(),
};
export type SerializedPlayerDescription = ObjectType<typeof serializedPlayerDescription>;
export class PlayerDescription {
playerId: GameId<'players'>;
name: string;
description: string;
character: string;
constructor(serialized: SerializedPlayerDescription) {
const { playerId, name, description, character } = serialized;
this.playerId = parseGameId('players', playerId);
this.name = name;
this.description = description;
this.character = character;
}
serialize(): SerializedPlayerDescription {
const { playerId, name, description, character } = this;
return {
playerId,
name,
description,
character,
};
}
}
================================================
FILE: convex/aiTown/schema.ts
================================================
import { v } from 'convex/values';
import { defineTable } from 'convex/server';
import { serializedPlayer } from './player';
import { serializedPlayerDescription } from './playerDescription';
import { serializedAgent } from './agent';
import { serializedAgentDescription } from './agentDescription';
import { serializedWorld } from './world';
import { serializedWorldMap } from './worldMap';
import { serializedConversation } from './conversation';
import { conversationId, playerId } from './ids';
export const aiTownTables = {
// This table has a single document that stores all players, conversations, and agents. This
// data is small and changes regularly over time.
worlds: defineTable({ ...serializedWorld }),
// Worlds can be started or stopped by the developer or paused for inactivity, and this
// infrequently changing document tracks this world state.
worldStatus: defineTable({
worldId: v.id('worlds'),
isDefault: v.boolean(),
engineId: v.id('engines'),
lastViewed: v.number(),
status: v.union(v.literal('running'), v.literal('stoppedByDeveloper'), v.literal('inactive')),
}).index('worldId', ['worldId']),
// This table contains the map data for a given world. Since it's a bit larger than the player
// state and infrequently changes, we store it in a separate table.
maps: defineTable({
worldId: v.id('worlds'),
...serializedWorldMap,
}).index('worldId', ['worldId']),
// Human readable text describing players and agents that's stored in separate tables, just like `maps`.
playerDescriptions: defineTable({
worldId: v.id('worlds'),
...serializedPlayerDescription,
}).index('worldId', ['worldId', 'playerId']),
agentDescriptions: defineTable({
worldId: v.id('worlds'),
...serializedAgentDescription,
}).index('worldId', ['worldId', 'agentId']),
//The game engine doesn't want to track players that have left or conversations that are over, since
// it wants to keep its managed state small. However, we may want to look at old conversations in the
// UI or from the agent code. So, whenever we delete an entry from within the world's document, we
// "archive" it within these tables.
archivedPlayers: defineTable({ worldId: v.id('worlds'), ...serializedPlayer }).index('worldId', [
'worldId',
'id',
]),
archivedConversations: defineTable({
worldId: v.id('worlds'),
id: conversationId,
creator: playerId,
created: v.number(),
ended: v.number(),
lastMessage: serializedConversation.lastMessage,
numMessages: serializedConversation.numMessages,
participants: v.array(playerId),
}).index('worldId', ['worldId', 'id']),
archivedAgents: defineTable({ worldId: v.id('worlds'), ...serializedAgent }).index('worldId', [
'worldId',
'id',
]),
// The agent layer wants to know what the last (completed) conversation was between two players,
// so this table represents a labelled graph indicating which players have talked to each other.
participatedTogether: defineTable({
worldId: v.id('worlds'),
conversationId,
player1: playerId,
player2: playerId,
ended: v.number(),
})
.index('edge', ['worldId', 'player1', 'player2', 'ended'])
.index('conversation', ['worldId', 'player1', 'conversationId'])
.index('playerHistory', ['worldId', 'player1', 'ended']),
};
================================================
FILE: convex/aiTown/world.ts
================================================
import { ObjectType, v } from 'convex/values';
import { Conversation, serializedConversation } from './conversation';
import { Player, serializedPlayer } from './player';
import { Agent, serializedAgent } from './agent';
import { GameId, parseGameId, playerId } from './ids';
import { parseMap } from '../util/object';
export const historicalLocations = v.array(
v.object({
playerId,
location: v.bytes(),
}),
);
export const serializedWorld = {
nextId: v.number(),
conversations: v.array(v.object(serializedConversation)),
players: v.array(v.object(serializedPlayer)),
agents: v.array(v.object(serializedAgent)),
historicalLocations: v.optional(historicalLocations),
};
export type SerializedWorld = ObjectType<typeof serializedWorld>;
export class World {
nextId: number;
conversations: Map<GameId<'conversations'>, Conversation>;
players: Map<GameId<'players'>, Player>;
agents: Map<GameId<'agents'>, Agent>;
historicalLocations?: Map<GameId<'players'>, ArrayBuffer>;
constructor(serialized: SerializedWorld) {
const { nextId, historicalLocations } = serialized;
this.nextId = nextId;
this.conversations = parseMap(serialized.conversations, Conversation, (c) => c.id);
this.players = parseMap(serialized.players, Player, (p) => p.id);
this.agents = parseMap(serialized.agents, Agent, (a) => a.id);
if (historicalLocations) {
this.historicalLocations = new Map();
for (const { playerId, location } of historicalLocations) {
this.historicalLocations.set(parseGameId('players', playerId), location);
}
}
}
playerConversation(player: Player): Conversation | undefined {
return [...this.conversations.values()].find((c) => c.participants.has(player.id));
}
serialize(): SerializedWorld {
return {
nextId: this.nextId,
conversations: [...this.conversations.values()].map((c) => c.serialize()),
players: [...this.players.values()].map((p) => p.serialize()),
agents: [...this.agents.values()].map((a) => a.serialize()),
historicalLocations:
this.historicalLocations &&
[...this.historicalLocations.entries()].map(([playerId, location]) => ({
playerId,
location,
})),
};
}
}
================================================
FILE: convex/aiTown/worldMap.ts
================================================
import { Infer, ObjectType, v } from 'convex/values';
// `layer[position.x][position.y]` is the tileIndex or -1 if empty.
const tileLayer = v.array(v.array(v.number()));
export type TileLayer = Infer<typeof tileLayer>;
const animatedSprite = {
x: v.number(),
y: v.number(),
w: v.number(),
h: v.number(),
layer: v.number(),
sheet: v.string(),
animation: v.string(),
};
export type AnimatedSprite = ObjectType<typeof animatedSprite>;
export const serializedWorldMap = {
width: v.number(),
height: v.number(),
tileSetUrl: v.string(),
// Width & height of tileset image, px.
tileSetDimX: v.number(),
tileSetDimY: v.number(),
// Tile size in pixels (assume square)
tileDim: v.number(),
bgTiles: v.array(v.array(v.array(v.number()))),
objectTiles: v.array(tileLayer),
animatedSprites: v.array(v.object(animatedSprite)),
};
export type SerializedWorldMap = ObjectType<typeof serializedWorldMap>;
export class WorldMap {
width: number;
height: number;
tileSetUrl: string;
tileSetDimX: number;
tileSetDimY: number;
tileDim: number;
bgTiles: TileLayer[];
objectTiles: TileLayer[];
animatedSprites: AnimatedSprite[];
constructor(serialized: SerializedWorldMap) {
this.width = serialized.width;
this.height = serialized.height;
this.tileSetUrl = serialized.tileSetUrl;
this.tileSetDimX = serialized.tileSetDimX;
this.tileSetDimY = serialized.tileSetDimY;
this.tileDim = serialized.tileDim;
this.bgTiles = serialized.bgTiles;
this.objectTiles = serialized.objectTiles;
this.animatedSprites = serialized.animatedSprites;
}
serialize(): SerializedWorldMap {
return {
width: this.width,
height: this.height,
tileSetUrl: this.tileSetUrl,
tileSetDimX: this.tileSetDimX,
tileSetDimY: this.tileSetDimY,
tileDim: this.tileDim,
bgTiles: this.bgTiles,
objectTiles: this.objectTiles,
animatedSprites: this.animatedSprites,
};
}
}
================================================
FILE: convex/constants.ts
================================================
export const ACTION_TIMEOUT = 120_000; // more time for local dev
// export const ACTION_TIMEOUT = 60_000;// normally fine
export const IDLE_WORLD_TIMEOUT = 5 * 60 * 1000;
export const WORLD_HEARTBEAT_INTERVAL = 60 * 1000;
export const MAX_STEP = 10 * 60 * 1000;
export const TICK = 16;
export const STEP_INTERVAL = 1000;
export const PATHFINDING_TIMEOUT = 60 * 1000;
export const PATHFINDING_BACKOFF = 1000;
export const CONVERSATION_DISTANCE = 1.3;
export const MIDPOINT_THRESHOLD = 4;
export const TYPING_TIMEOUT = 15 * 1000;
export const COLLISION_THRESHOLD = 0.75;
// How many human players can be in a world at once.
export const MAX_HUMAN_PLAYERS = 8;
// Don't talk to anyone for 15s after having a conversation.
export const CONVERSATION_COOLDOWN = 15000;
// Don't do another activity for 10s after doing one.
export const ACTIVITY_COOLDOWN = 10_000;
// Don't talk to a player within 60s of talking to them.
export const PLAYER_CONVERSATION_COOLDOWN = 60000;
// Invite 80% of invites that come from other agents.
export const INVITE_ACCEPT_PROBABILITY = 0.8;
// Wait for 1m for invites to be accepted.
export const INVITE_TIMEOUT = 60000;
// Wait for another player to say something before jumping in.
export const AWKWARD_CONVERSATION_TIMEOUT = 60_000; // more time locally
// export const AWKWARD_CONVERSATION_TIMEOUT = 20_000;
// Leave a conversation after participating too long.
export const MAX_CONVERSATION_DURATION = 10 * 60_000; // more time locally
// export const MAX_CONVERSATION_DURATION = 2 * 60_000;
// Leave a conversation if it has more than 8 messages;
export const MAX_CONVERSATION_MESSAGES = 8;
// Wait for 1s after sending an input to the engine. We can remove this
// once we can await on an input being processed.
export const INPUT_DELAY = 1000;
// How many memories to get from the agent's memory.
// This is over-fetched by 10x so we can prioritize memories by more than relevance.
export const NUM_MEMORIES_TO_SEARCH = 3;
// Wait for at least two seconds before sending another message.
export const MESSAGE_COOLDOWN = 2000;
// Don't run a turn of the agent more than once a second.
export const AGENT_WAKEUP_THRESHOLD = 1000;
// How old we let memories be before we vacuum them
export const VACUUM_MAX_AGE = 2 * 7 * 24 * 60 * 60 * 1000;
export const DELETE_BATCH_SIZE = 64;
export const HUMAN_IDLE_TOO_LONG = 5 * 60 * 1000;
export const ACTIVITIES = [
{ description: 'reading a book', emoji: '📖', duration: 60_000 },
{ description: 'daydreaming', emoji: '🤔', duration: 60_000 },
{ description: 'gardening', emoji: '🥕', duration: 60_000 },
];
export const ENGINE_ACTION_DURATION = 30000;
// Bound the number of pathfinding searches we do per game step.
export const MAX_PATHFINDS_PER_STEP = 16;
export const DEFAULT_NAME = 'Me';
================================================
FILE: convex/crons.ts
================================================
import { cronJobs } from 'convex/server';
import { DELETE_BATCH_SIZE, IDLE_WORLD_TIMEOUT, VACUUM_MAX_AGE } from './constants';
import { internal } from './_generated/api';
import { internalMutation } from './_generated/server';
import { TableNames } from './_generated/dataModel';
import { v } from 'convex/values';
const crons = cronJobs();
crons.interval(
'stop inactive worlds',
{ seconds: IDLE_WORLD_TIMEOUT / 1000 },
internal.world.stopInactiveWorlds,
);
crons.interval('restart dead worlds', { seconds: 60 }, internal.world.restartDeadWorlds);
crons.daily('vacuum old entries', { hourUTC: 4, minuteUTC: 20 }, internal.crons.vacuumOldEntries);
export default crons;
const TablesToVacuum: TableNames[] = [
// Un-comment this to also clean out old conversations.
// 'conversationMembers', 'conversations', 'messages',
// Inputs aren't useful unless you're trying to replay history.
// If you want to support that, you should add a snapshot table, so you can
// replay from a certain time period. Or stop vacuuming inputs and replay from
// the beginning of time
'inputs',
// We can keep memories without their embeddings for inspection, but we won't
// retrieve them when searching memories via vector search.
'memories',
// We can vacuum fewer tables without serious consequences, but the only
// one that will cause issues over time is having >>100k vectors.
'memoryEmbeddings',
];
export const vacuumOldEntries = internalMutation({
args: {},
handler: async (ctx, args) => {
const before = Date.now() - VACUUM_MAX_AGE;
for (const tableName of TablesToVacuum) {
console.log(`Checking ${tableName}...`);
const exists = await ctx.db
.query(tableName)
.withIndex('by_creation_time', (q) => q.lt('_creationTime', before))
.first();
if (exists) {
console.log(`Vacuuming ${tableName}...`);
await ctx.scheduler.runAfter(0, internal.crons.vacuumTable, {
tableName,
before,
cursor: null,
soFar: 0,
});
}
}
},
});
export const vacuumTable = internalMutation({
args: {
tableName: v.string(),
before: v.number(),
cursor: v.union(v.string(), v.null()),
soFar: v.number(),
},
handler: async (ctx, { tableName, before, cursor, soFar }) => {
const results = await ctx.db
.query(tableName as TableNames)
.withIndex('by_creation_time', (q) => q.lt('_creationTime', before))
.paginate({ cursor, numItems: DELETE_BATCH_SIZE });
for (const row of results.page) {
await ctx.db.delete(row._id);
}
if (!results.isDone) {
await ctx.scheduler.runAfter(0, internal.crons.vacuumTable, {
tableName,
before,
soFar: results.page.length + soFar,
cursor: results.continueCursor,
});
} else {
console.log(`Vacuumed ${soFar + results.page.length} entries from ${tableName}`);
}
},
});
================================================
FILE: convex/engine/abstractGame.ts
================================================
import { ConvexError, Infer, Value, v } from 'convex/values';
import { Doc, Id } from '../_generated/dataModel';
import { ActionCtx, DatabaseReader, MutationCtx, internalQuery } from '../_generated/server';
import { engine } from '../engine/schema';
import { internal } from '../_generated/api';
export abstract class AbstractGame {
abstract tickDuration: number;
abstract stepDuration: number;
abstract maxTicksPerStep: number;
abstract maxInputsPerStep: number;
constructor(public engine: Doc<'engines'>) {}
abstract handleInput(now: number, name: string, args: object): Value;
abstract tick(now: number): void;
// Optional callback at the beginning of each step.
beginStep(now: number) {}
abstract saveStep(ctx: ActionCtx, engineUpdate: EngineUpdate): Promise<void>;
async runStep(ctx: ActionCtx, now: number) {
const inputs = await ctx.runQuery(internal.engine.abstractGame.loadInputs, {
engineId: this.engine._id,
processedInputNumber: this.engine.processedInputNumber,
max: this.maxInputsPerStep,
});
const lastStepTs = this.engine.currentTime;
const startTs = lastStepTs ? lastStepTs + this.tickDuration : now;
let currentTs = startTs;
let inputIndex = 0;
let numTicks = 0;
let processedInputNumber = this.engine.processedInputNumber;
const completedInputs = [];
this.beginStep(currentTs);
while (numTicks < this.maxTicksPerStep) {
numTicks += 1;
// Collect all of the inputs for this tick.
const tickInputs = [];
while (inputIndex < inputs.length) {
const input = inputs[inputIndex];
if (input.received > currentTs) {
break;
}
inputIndex += 1;
processedInputNumber = input.number;
tickInputs.push(input);
}
// Feed the inputs to the game.
for (const input of tickInputs) {
let returnValue;
try {
const value = this.handleInput(currentTs, input.name, input.args);
returnValue = { kind: 'ok' as const, value };
} catch (e: any) {
console.error(`Input ${input._id} failed: ${e.message}`);
returnValue = { kind: 'error' as const, message: e.message };
}
completedInputs.push({ inputId: input._id, returnValue });
}
// Simulate the game forward one tick.
this.tick(currentTs);
const candidateTs = currentTs + this.tickDuration;
if (now < candidateTs) {
break;
}
currentTs = candidateTs;
}
// Commit the step by moving time forward, consuming our inputs, and saving the game's state.
const expectedGenerationNumber = this.engine.generationNumber;
this.engine.currentTime = currentTs;
this.engine.lastStepTs = lastStepTs;
this.engine.generationNumber += 1;
this.engine.processedInputNumber = processedInputNumber;
const { _id, _creationTime, ...engine } = this.engine;
const engineUpdate = { engine, completedInputs, expectedGenerationNumber };
await this.saveStep(ctx, engineUpdate);
console.debug(`Simulated from ${startTs} to ${currentTs} (${currentTs - startTs}ms)`);
}
}
const completedInput = v.object({
inputId: v.id('inputs'),
returnValue: v.union(
v.object({
kind: v.literal('ok'),
value: v.any(),
}),
v.object({
kind: v.literal('error'),
message: v.string(),
}),
),
});
export const engineUpdate = v.object({
engine,
expectedGenerationNumber: v.number(),
completedInputs: v.array(completedInput),
});
export type EngineUpdate = Infer<typeof engineUpdate>;
export async function loadEngine(
db: DatabaseReader,
engineId: Id<'engines'>,
generationNumber: number,
) {
const engine = await db.get(engineId);
if (!engine) {
throw new Error(`No engine found with id ${engineId}`);
}
if (!engine.running) {
throw new ConvexError({
kind: 'engineNotRunning',
message: `Engine ${engineId} is not running`,
});
}
if (engine.generationNumber !== generationNumber) {
throw new ConvexError({ kind: 'generationNumber', message: 'Generation number mismatch' });
}
return engine;
}
export async function engineInsertInput(
ctx: MutationCtx,
engineId: Id<'engines'>,
name: string,
args: any,
): Promise<Id<'inputs'>> {
const now = Date.now();
const prevInput = await ctx.db
.query('inputs')
.withIndex('byInputNumber', (q) => q.eq('engineId', engineId))
.order('desc')
.first();
const number = prevInput ? prevInput.number + 1 : 0;
const inputId = await ctx.db.insert('inputs', {
engineId,
number,
name,
args,
received: now,
});
return inputId;
}
export const loadInputs = internalQuery({
args: {
engineId: v.id('engines'),
processedInputNumber: v.optional(v.number()),
max: v.number(),
},
handler: async (ctx, args) => {
return await ctx.db
.query('inputs')
.withIndex('byInputNumber', (q) =>
q.eq('engineId', args.engineId).gt('number', args.processedInputNumber ?? -1),
)
.order('asc')
.take(args.max);
},
});
export async function applyEngineUpdate(
ctx: MutationCtx,
engineId: Id<'engines'>,
update: EngineUpdate,
) {
const engine = await loadEngine(ctx.db, engineId, update.expectedGenerationNumber);
if (
engine.currentTime &&
update.engine.currentTime &&
update.engine.currentTime < engine.currentTime
gitextract_ov7oqzfx/ ├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vercelignore ├── .vscode/ │ ├── convex.code-snippets │ └── settings.json ├── ARCHITECTURE.md ├── Dockerfile ├── LICENSE ├── README.md ├── convex/ │ ├── _generated/ │ │ ├── api.d.ts │ │ ├── api.js │ │ ├── dataModel.d.ts │ │ ├── server.d.ts │ │ └── server.js │ ├── agent/ │ │ ├── conversation.ts │ │ ├── embeddingsCache.ts │ │ ├── memory.ts │ │ └── schema.ts │ ├── aiTown/ │ │ ├── agent.ts │ │ ├── agentDescription.ts │ │ ├── agentInputs.ts │ │ ├── agentOperations.ts │ │ ├── conversation.ts │ │ ├── conversationMembership.ts │ │ ├── game.ts │ │ ├── ids.ts │ │ ├── inputHandler.ts │ │ ├── inputs.ts │ │ ├── insertInput.ts │ │ ├── location.ts │ │ ├── main.ts │ │ ├── movement.ts │ │ ├── player.ts │ │ ├── playerDescription.ts │ │ ├── schema.ts │ │ ├── world.ts │ │ └── worldMap.ts │ ├── constants.ts │ ├── crons.ts │ ├── engine/ │ │ ├── abstractGame.ts │ │ ├── historicalObject.test.ts │ │ ├── historicalObject.ts │ │ └── schema.ts │ ├── http.ts │ ├── init.ts │ ├── messages.ts │ ├── music.ts │ ├── schema.ts │ ├── testing.ts │ ├── util/ │ │ ├── FastIntegerCompression.ts │ │ ├── assertNever.ts │ │ ├── asyncMap.test.ts │ │ ├── asyncMap.ts │ │ ├── compression.test.ts │ │ ├── compression.ts │ │ ├── geometry.test.ts │ │ ├── geometry.ts │ │ ├── isSimpleObject.ts │ │ ├── llm.ts │ │ ├── minheap.test.ts │ │ ├── minheap.ts │ │ ├── object.ts │ │ ├── sleep.ts │ │ ├── types.test.ts │ │ ├── types.ts │ │ └── xxhash.ts │ └── world.ts ├── data/ │ ├── animations/ │ │ ├── campfire.json │ │ ├── gentlesparkle.json │ │ ├── gentlesplash.json │ │ ├── gentlewaterfall.json │ │ └── windmill.json │ ├── characters.ts │ ├── convertMap.js │ ├── gentle.js │ └── spritesheets/ │ ├── f1.ts │ ├── f2.ts │ ├── f3.ts │ ├── f4.ts │ ├── f5.ts │ ├── f6.ts │ ├── f7.ts │ ├── f8.ts │ ├── p1.ts │ ├── p2.ts │ ├── p3.ts │ ├── player.ts │ └── types.ts ├── docker-compose.yml ├── fly/ │ ├── README.md │ ├── backend/ │ │ └── fly.toml │ └── dashboard/ │ └── fly.toml ├── index.html ├── jest.config.ts ├── package.json ├── postcss.config.js ├── public/ │ └── assets/ │ └── tilemap.json ├── src/ │ ├── App.tsx │ ├── components/ │ │ ├── Character.tsx │ │ ├── ConvexClientProvider.tsx │ │ ├── DebugPath.tsx │ │ ├── DebugTimeManager.tsx │ │ ├── FreezeButton.tsx │ │ ├── Game.tsx │ │ ├── MessageInput.tsx │ │ ├── Messages.tsx │ │ ├── PixiGame.tsx │ │ ├── PixiStaticMap.tsx │ │ ├── PixiViewport.tsx │ │ ├── Player.tsx │ │ ├── PlayerDetails.tsx │ │ ├── PositionIndicator.tsx │ │ ├── PoweredByConvex.tsx │ │ └── buttons/ │ │ ├── Button.tsx │ │ ├── InteractButton.tsx │ │ ├── LoginButton.tsx │ │ └── MusicButton.tsx │ ├── editor/ │ │ ├── README.md │ │ ├── campfire.json │ │ ├── eutils.js │ │ ├── gentlesparkle.json │ │ ├── gentlesplash.json │ │ ├── gentlewaterfall.json │ │ ├── index.html │ │ ├── le.html │ │ ├── le.js │ │ ├── leconfig.js │ │ ├── lecontext.js │ │ ├── lehtmlui.js │ │ ├── mapfile.js │ │ ├── maps/ │ │ │ ├── gentle-full.js │ │ │ ├── gentle.js │ │ │ ├── gentleanim.js │ │ │ ├── mage3.js │ │ │ └── serene.js │ │ ├── se.html │ │ ├── se.js │ │ ├── seconfig.js │ │ ├── secontext.js │ │ ├── sehtmlui.js │ │ ├── spritefile.js │ │ ├── undo.js │ │ └── windmill.json │ ├── hooks/ │ │ ├── sendInput.ts │ │ ├── serverGame.ts │ │ ├── useHistoricalTime.ts │ │ ├── useHistoricalValue.ts │ │ └── useWorldHeartbeat.ts │ ├── index.css │ ├── main.tsx │ ├── toasts.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── vercel.json └── vite.config.ts
SYMBOL INDEX (434 symbols across 79 files)
FILE: convex/_generated/dataModel.d.ts
type TableNames (line 23) | type TableNames = TableNamesInDataModel<DataModel>;
type Doc (line 30) | type Doc<TableName extends TableNames> = DocumentByName<
type Id (line 48) | type Id<TableName extends TableNames | SystemTableNames> =
type DataModel (line 60) | type DataModel = DataModelFromSchemaDefinition<typeof schema>;
FILE: convex/_generated/server.d.ts
type QueryCtx (line 106) | type QueryCtx = GenericQueryCtx<DataModel>;
type MutationCtx (line 114) | type MutationCtx = GenericMutationCtx<DataModel>;
type ActionCtx (line 122) | type ActionCtx = GenericActionCtx<DataModel>;
type DatabaseReader (line 131) | type DatabaseReader = GenericDatabaseReader<DataModel>;
type DatabaseWriter (line 142) | type DatabaseWriter = GenericDatabaseWriter<DataModel>;
FILE: convex/agent/conversation.ts
function startConversationMessage (line 13) | async function startConversationMessage(
function trimContentPrefx (line 71) | function trimContentPrefx(content: string, prompt: string) {
function continueConversationMessage (line 78) | async function continueConversationMessage(
function leaveConversationMessage (line 136) | async function leaveConversationMessage(
function agentPrompts (line 185) | function agentPrompts(
function previousConversationPrompt (line 201) | function previousConversationPrompt(
function relatedMemoriesPrompt (line 218) | function relatedMemoriesPrompt(memories: memory.Memory[]): string[] {
function previousMessages (line 229) | async function previousMessages(
function stopWords (line 348) | function stopWords(otherPlayer: string, player: string) {
FILE: convex/agent/embeddingsCache.ts
function fetch (line 9) | async function fetch(ctx: ActionCtx, text: string) {
function fetchBatch (line 14) | async function fetchBatch(ctx: ActionCtx, texts: string[]) {
function hashText (line 54) | async function hashText(text: string) {
FILE: convex/agent/memory.ts
constant MEMORY_ACCESS_THROTTLE (line 12) | const MEMORY_ACCESS_THROTTLE = 300_000;
constant MEMORY_OVERFETCH (line 15) | const MEMORY_OVERFETCH = 10;
type Memory (line 18) | type Memory = Doc<'memories'>;
type MemoryType (line 19) | type MemoryType = Memory['data']['type'];
type MemoryOfType (line 20) | type MemoryOfType<T extends MemoryType> = Omit<Memory, 'data'> & {
function rememberConversation (line 24) | async function rememberConversation(
function searchMemories (line 158) | async function searchMemories(
function makeRange (line 176) | function makeRange(values: number[]) {
function normalize (line 182) | function normalize(value: number, range: readonly [number, number]) {
function calculateImportance (line 246) | async function calculateImportance(description: string) {
function reflectOnMemories (line 325) | async function reflectOnMemories(
function latestMemoryOfType (line 438) | async function latestMemoryOfType<T extends MemoryType>(
FILE: convex/aiTown/agent.ts
class Agent (line 26) | class Agent {
method constructor (line 38) | constructor(serialized: SerializedAgent) {
method tick (line 52) | tick(game: Game, now: number) {
method startOperation (line 238) | startOperation<Name extends keyof AgentOperations>(
method serialize (line 259) | serialize(): SerializedAgent {
type SerializedAgent (line 285) | type SerializedAgent = ObjectType<typeof serializedAgent>;
type AgentOperations (line 287) | type AgentOperations = typeof internal.aiTown.agentOperations;
function runAgentOperation (line 289) | async function runAgentOperation(ctx: MutationCtx, operation: string, ar...
FILE: convex/aiTown/agentDescription.ts
class AgentDescription (line 4) | class AgentDescription {
method constructor (line 9) | constructor(serialized: SerializedAgentDescription) {
method serialize (line 16) | serialize(): SerializedAgentDescription {
type SerializedAgentDescription (line 27) | type SerializedAgentDescription = ObjectType<typeof serializedAgentDescr...
FILE: convex/aiTown/agentOperations.ts
function wanderDestination (line 172) | function wanderDestination(worldMap: WorldMap) {
FILE: convex/aiTown/conversation.ts
class Conversation (line 15) | class Conversation {
method constructor (line 31) | constructor(serialized: SerializedConversation) {
method tick (line 49) | tick(game: Game, now: number) {
method start (line 122) | static start(game: Game, now: number, player: Player, invitee: Player) {
method setIsTyping (line 155) | setIsTyping(now: number, player: Player, messageUuid: string) {
method acceptInvite (line 165) | acceptInvite(game: Game, player: Player) {
method rejectInvite (line 178) | rejectInvite(game: Game, now: number, player: Player) {
method stop (line 193) | stop(game: Game, now: number) {
method leave (line 205) | leave(game: Game, now: number, player: Player) {
method serialize (line 213) | serialize(): SerializedConversation {
type SerializedConversation (line 247) | type SerializedConversation = ObjectType<typeof serializedConversation>;
FILE: convex/aiTown/conversationMembership.ts
type SerializedConversationMembership (line 13) | type SerializedConversationMembership = ObjectType<typeof serializedConv...
class ConversationMembership (line 15) | class ConversationMembership {
method constructor (line 23) | constructor(serialized: SerializedConversationMembership) {
method serialize (line 30) | serialize(): SerializedConversationMembership {
FILE: convex/aiTown/game.ts
type GameState (line 35) | type GameState = Infer<typeof gameState>;
type GameStateDiff (line 44) | type GameStateDiff = Infer<typeof gameStateDiff>;
class Game (line 46) | class Game extends AbstractGame {
method constructor (line 65) | constructor(
method load (line 89) | static async load(
method allocId (line 147) | allocId<T extends IdTypes>(idType: T): GameId<T> {
method scheduleOperation (line 153) | scheduleOperation(name: string, args: unknown) {
method handleInput (line 157) | handleInput<Name extends InputNames>(now: number, name: Name, args: In...
method beginStep (line 165) | beginStep(_now: number) {
method tick (line 177) | tick(now: number) {
method saveStep (line 206) | async saveStep(ctx: ActionCtx, engineUpdate: EngineUpdate): Promise<vo...
method takeDiff (line 216) | takeDiff(): GameStateDiff {
method saveDiff (line 250) | static async saveDiff(ctx: MutationCtx, worldId: Id<'worlds'>, diff: G...
FILE: convex/aiTown/ids.ts
type IdTypes (line 4) | type IdTypes = keyof typeof IdShortCodes;
type GameId (line 6) | type GameId<T extends IdTypes> = string & { __type: T };
function parseGameId (line 8) | function parseGameId<T extends IdTypes>(idType: T, gameId: string): Game...
function allocGameId (line 21) | function allocGameId<T extends IdTypes>(idType: T, idNumber: number): Ga...
FILE: convex/aiTown/inputHandler.ts
function inputHandler (line 4) | function inputHandler<ArgsValidator extends PropertyValidators, Return e...
FILE: convex/aiTown/inputs.ts
type Inputs (line 18) | type Inputs = typeof inputs;
type InputNames (line 19) | type InputNames = keyof Inputs;
type InputArgs (line 20) | type InputArgs<Name extends InputNames> = ObjectType<Inputs[Name]['args']>;
type InputReturnValue (line 21) | type InputReturnValue<Name extends InputNames> = ReturnType<
FILE: convex/aiTown/insertInput.ts
function insertInput (line 6) | async function insertInput<Name extends InputNames>(
FILE: convex/aiTown/location.ts
type Location (line 4) | type Location = {
function playerLocation (line 24) | function playerLocation(player: Player): Location {
FILE: convex/aiTown/main.ts
function createEngine (line 10) | async function createEngine(ctx: MutationCtx) {
function loadWorldStatus (line 20) | async function loadWorldStatus(db: DatabaseReader, worldId: Id<'worlds'>) {
function startEngine (line 31) | async function startEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
function kickEngine (line 59) | async function kickEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
function stopEngine (line 77) | async function stopEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
FILE: convex/aiTown/movement.ts
type PathCandidate (line 11) | type PathCandidate = {
function stopPlayer (line 20) | function stopPlayer(player: Player) {
function movePlayer (line 25) | function movePlayer(
function findRoute (line 57) | function findRoute(game: Game, now: number, player: Player, destination:...
function blocked (line 164) | function blocked(game: Game, now: number, pos: Point, playerId?: GameId<...
function blockedWithPositions (line 171) | function blockedWithPositions(position: Point, otherPositions: Point[], ...
FILE: convex/aiTown/player.ts
type Pathfinding (line 36) | type Pathfinding = Infer<typeof pathfinding>;
type Activity (line 43) | type Activity = Infer<typeof activity>;
type SerializedPlayer (line 58) | type SerializedPlayer = ObjectType<typeof serializedPlayer>;
class Player (line 60) | class Player {
method constructor (line 72) | constructor(serialized: SerializedPlayer) {
method tick (line 84) | tick(game: Game, now: number) {
method tickPathfinding (line 90) | tickPathfinding(game: Game, now: number) {
method tickPosition (line 137) | tickPosition(game: Game, now: number) {
method join (line 168) | static join(
method leave (line 240) | leave(game: Game, now: number) {
method serialize (line 251) | serialize(): SerializedPlayer {
FILE: convex/aiTown/playerDescription.ts
type SerializedPlayerDescription (line 10) | type SerializedPlayerDescription = ObjectType<typeof serializedPlayerDes...
class PlayerDescription (line 12) | class PlayerDescription {
method constructor (line 18) | constructor(serialized: SerializedPlayerDescription) {
method serialize (line 26) | serialize(): SerializedPlayerDescription {
FILE: convex/aiTown/world.ts
type SerializedWorld (line 22) | type SerializedWorld = ObjectType<typeof serializedWorld>;
class World (line 24) | class World {
method constructor (line 31) | constructor(serialized: SerializedWorld) {
method playerConversation (line 47) | playerConversation(player: Player): Conversation | undefined {
method serialize (line 51) | serialize(): SerializedWorld {
FILE: convex/aiTown/worldMap.ts
type TileLayer (line 5) | type TileLayer = Infer<typeof tileLayer>;
type AnimatedSprite (line 16) | type AnimatedSprite = ObjectType<typeof animatedSprite>;
type SerializedWorldMap (line 33) | type SerializedWorldMap = ObjectType<typeof serializedWorldMap>;
class WorldMap (line 35) | class WorldMap {
method constructor (line 49) | constructor(serialized: SerializedWorldMap) {
method serialize (line 61) | serialize(): SerializedWorldMap {
FILE: convex/constants.ts
constant ACTION_TIMEOUT (line 1) | const ACTION_TIMEOUT = 120_000;
constant IDLE_WORLD_TIMEOUT (line 4) | const IDLE_WORLD_TIMEOUT = 5 * 60 * 1000;
constant WORLD_HEARTBEAT_INTERVAL (line 5) | const WORLD_HEARTBEAT_INTERVAL = 60 * 1000;
constant MAX_STEP (line 7) | const MAX_STEP = 10 * 60 * 1000;
constant TICK (line 8) | const TICK = 16;
constant STEP_INTERVAL (line 9) | const STEP_INTERVAL = 1000;
constant PATHFINDING_TIMEOUT (line 11) | const PATHFINDING_TIMEOUT = 60 * 1000;
constant PATHFINDING_BACKOFF (line 12) | const PATHFINDING_BACKOFF = 1000;
constant CONVERSATION_DISTANCE (line 13) | const CONVERSATION_DISTANCE = 1.3;
constant MIDPOINT_THRESHOLD (line 14) | const MIDPOINT_THRESHOLD = 4;
constant TYPING_TIMEOUT (line 15) | const TYPING_TIMEOUT = 15 * 1000;
constant COLLISION_THRESHOLD (line 16) | const COLLISION_THRESHOLD = 0.75;
constant MAX_HUMAN_PLAYERS (line 19) | const MAX_HUMAN_PLAYERS = 8;
constant CONVERSATION_COOLDOWN (line 22) | const CONVERSATION_COOLDOWN = 15000;
constant ACTIVITY_COOLDOWN (line 25) | const ACTIVITY_COOLDOWN = 10_000;
constant PLAYER_CONVERSATION_COOLDOWN (line 28) | const PLAYER_CONVERSATION_COOLDOWN = 60000;
constant INVITE_ACCEPT_PROBABILITY (line 31) | const INVITE_ACCEPT_PROBABILITY = 0.8;
constant INVITE_TIMEOUT (line 34) | const INVITE_TIMEOUT = 60000;
constant AWKWARD_CONVERSATION_TIMEOUT (line 37) | const AWKWARD_CONVERSATION_TIMEOUT = 60_000;
constant MAX_CONVERSATION_DURATION (line 41) | const MAX_CONVERSATION_DURATION = 10 * 60_000;
constant MAX_CONVERSATION_MESSAGES (line 45) | const MAX_CONVERSATION_MESSAGES = 8;
constant INPUT_DELAY (line 49) | const INPUT_DELAY = 1000;
constant NUM_MEMORIES_TO_SEARCH (line 53) | const NUM_MEMORIES_TO_SEARCH = 3;
constant MESSAGE_COOLDOWN (line 56) | const MESSAGE_COOLDOWN = 2000;
constant AGENT_WAKEUP_THRESHOLD (line 59) | const AGENT_WAKEUP_THRESHOLD = 1000;
constant VACUUM_MAX_AGE (line 62) | const VACUUM_MAX_AGE = 2 * 7 * 24 * 60 * 60 * 1000;
constant DELETE_BATCH_SIZE (line 63) | const DELETE_BATCH_SIZE = 64;
constant HUMAN_IDLE_TOO_LONG (line 65) | const HUMAN_IDLE_TOO_LONG = 5 * 60 * 1000;
constant ACTIVITIES (line 67) | const ACTIVITIES = [
constant ENGINE_ACTION_DURATION (line 73) | const ENGINE_ACTION_DURATION = 30000;
constant MAX_PATHFINDS_PER_STEP (line 76) | const MAX_PATHFINDS_PER_STEP = 16;
constant DEFAULT_NAME (line 78) | const DEFAULT_NAME = 'Me';
FILE: convex/engine/abstractGame.ts
method constructor (line 13) | constructor(public engine: Doc<'engines'>) {}
method beginStep (line 19) | beginStep(now: number) {}
method runStep (line 22) | async runStep(ctx: ActionCtx, now: number) {
type EngineUpdate (line 110) | type EngineUpdate = Infer<typeof engineUpdate>;
function loadEngine (line 112) | async function loadEngine(
function engineInsertInput (line 133) | async function engineInsertInput(
function applyEngineUpdate (line 173) | async function applyEngineUpdate(
FILE: convex/engine/historicalObject.ts
type FieldConfig (line 20) | type FieldConfig = Array<string | { name: string; precision: number }>;
constant MAX_FIELDS (line 23) | const MAX_FIELDS = 16;
constant PACKED_VERSION (line 25) | const PACKED_VERSION = 1;
type NormalizedFieldConfig (line 27) | type NormalizedFieldConfig = Array<{
type History (line 52) | type History = {
type Sample (line 57) | type Sample = {
class HistoricalObject (line 72) | class HistoricalObject<T extends Record<string, number>> {
method constructor (line 80) | constructor(fields: FieldConfig, initialValue: T) {
method historyLength (line 89) | historyLength() {
method checkShape (line 95) | checkShape(data: any) {
method update (line 108) | update(now: number, data: T) {
method pack (line 137) | pack(): ArrayBuffer | null {
function packFieldConfig (line 155) | function packFieldConfig(fields: NormalizedFieldConfig) {
function packSampleRecord (line 213) | function packSampleRecord(
function unpackSampleRecord (line 283) | function unpackSampleRecord(fields: FieldConfig, buffer: ArrayBuffer) {
function normalizeFieldConfig (line 353) | function normalizeFieldConfig(fields: FieldConfig): NormalizedFieldConfig {
FILE: convex/engine/schema.ts
type Engine (line 51) | type Engine = Infer<typeof engine>;
FILE: convex/init.ts
function getOrCreateDefaultWorld (line 42) | async function getOrCreateDefaultWorld(ctx: MutationCtx) {
function shouldCreateAgents (line 90) | async function shouldCreateAgents(
FILE: convex/music.ts
function client (line 7) | function client(): Replicate {
function replicateAvailable (line 14) | function replicateAvailable(): boolean {
type MusicGenNormStrategy (line 73) | enum MusicGenNormStrategy {
type MusicGenFormat (line 80) | enum MusicGenFormat {
function generateMusic (line 100) | async function generateMusic(
FILE: convex/testing.ts
function getDefaultWorld (line 114) | async function getDefaultWorld(db: DatabaseReader) {
FILE: convex/util/FastIntegerCompression.ts
function bytelog (line 19) | function bytelog(val: number) {
function zigzag_encode (line 32) | function zigzag_encode(val: number) {
function zigzag_decode (line 36) | function zigzag_decode(val: number) {
function computeCompressedSizeInBytes (line 42) | function computeCompressedSizeInBytes(input: number[]) {
function computeCompressedSizeInBytesSigned (line 53) | function computeCompressedSizeInBytesSigned(input: number[]) {
function compress (line 65) | function compress(input: number[]) {
function computeHowManyIntegers (line 99) | function computeHowManyIntegers(input: ArrayBuffer) {
function uncompress (line 111) | function uncompress(input: ArrayBuffer) {
function compressSigned (line 151) | function compressSigned(input: number[]) {
function uncompressSigned (line 186) | function uncompressSigned(input: ArrayBuffer) {
FILE: convex/util/assertNever.ts
function assertNever (line 2) | function assertNever(x: never): never {
FILE: convex/util/asyncMap.ts
function asyncMap (line 9) | async function asyncMap<FromType, ToType>(
FILE: convex/util/compression.ts
function quantize (line 1) | function quantize(values: number[], precision: number) {
function unquantize (line 6) | function unquantize(quantized: number[], precision: number) {
function deltaEncode (line 11) | function deltaEncode(values: number[], initialValue = 0) {
function deltaDecode (line 21) | function deltaDecode(deltas: number[], initialValue = 0) {
function runLengthEncode (line 32) | function runLengthEncode(values: number[]) {
function runLengthDecode (line 58) | function runLengthDecode(encoded: number[]) {
FILE: convex/util/geometry.ts
function distance (line 3) | function distance(p0: Point, p1: Point): number {
function pointsEqual (line 9) | function pointsEqual(p0: Point, p1: Point): boolean {
function manhattanDistance (line 13) | function manhattanDistance(p0: Point, p1: Point) {
function pathOverlaps (line 17) | function pathOverlaps(path: Path, time: number): boolean {
function pathPosition (line 26) | function pathPosition(
constant EPSILON (line 60) | const EPSILON = 0.0001;
function vector (line 62) | function vector(p0: Point, p1: Point): Vector {
function vectorLength (line 68) | function vectorLength(vector: Vector): number {
function normalize (line 72) | function normalize(vector: Vector): Vector | null {
function orientationDegrees (line 84) | function orientationDegrees(vector: Vector): number {
function compressPath (line 93) | function compressPath(densePath: PathComponent[]): Path {
FILE: convex/util/isSimpleObject.ts
function isSimpleObject (line 1) | function isSimpleObject(value: unknown) {
FILE: convex/util/llm.ts
constant OPENAI_EMBEDDING_DIMENSION (line 3) | const OPENAI_EMBEDDING_DIMENSION = 1536;
constant TOGETHER_EMBEDDING_DIMENSION (line 4) | const TOGETHER_EMBEDDING_DIMENSION = 768;
constant OLLAMA_EMBEDDING_DIMENSION (line 5) | const OLLAMA_EMBEDDING_DIMENSION = 1024;
constant EMBEDDING_DIMENSION (line 7) | const EMBEDDING_DIMENSION: number = OLLAMA_EMBEDDING_DIMENSION;
function detectMismatchedLLMProvider (line 9) | function detectMismatchedLLMProvider() {
type LLMConfig (line 37) | interface LLMConfig {
function getLLMConfig (line 46) | function getLLMConfig(): LLMConfig {
function chatCompletion (line 135) | async function chatCompletion(
function tryPullOllama (line 190) | async function tryPullOllama(model: string, error: string) {
function fetchEmbeddingBatch (line 205) | async function fetchEmbeddingBatch(texts: string[]) {
function fetchEmbedding (line 255) | async function fetchEmbedding(text: string) {
function fetchModeration (line 260) | async function fetchModeration(content: string) {
constant RETRY_BACKOFF (line 285) | const RETRY_BACKOFF = [1000, 10_000, 20_000];
constant RETRY_JITTER (line 286) | const RETRY_JITTER = 100;
type RetryError (line 287) | type RetryError = { retry: boolean; error: any };
function retryWithBackoff (line 289) | async function retryWithBackoff<T>(
type LLMMessage (line 321) | interface LLMMessage {
type CreateChatCompletionResponse (line 359) | interface CreateChatCompletionResponse {
type CreateEmbeddingResponse (line 381) | interface CreateEmbeddingResponse {
type CreateChatCompletionRequest (line 395) | interface CreateChatCompletionRequest {
class ChatCompletionContent (line 601) | class ChatCompletionContent {
method constructor (line 605) | constructor(body: ReadableStream<Uint8Array>, stopWords: string[]) {
method readInner (line 610) | async *readInner() {
method read (line 629) | async *read() {
method readAll (line 651) | async readAll() {
method splitStream (line 659) | async *splitStream(stream: ReadableStream<Uint8Array>) {
function ollamaFetchEmbedding (line 688) | async function ollamaFetchEmbedding(text: string) {
FILE: convex/util/minheap.ts
function MinHeap (line 2) | function MinHeap<T>(compare: (a: T, b: T) => boolean) {
FILE: convex/util/object.ts
function parseMap (line 1) | function parseMap<Id, Serialized, Parsed>(
function serializeMap (line 18) | function serializeMap<Serialized, T extends { serialize(): Serialized }>(
FILE: convex/util/sleep.ts
function sleep (line 1) | async function sleep(ms: number) {
FILE: convex/util/types.ts
type Point (line 7) | type Point = Infer<typeof point>;
type Vector (line 13) | type Vector = Infer<typeof vector>;
type Path (line 17) | type Path = [number, number, number, number, number][];
type PathComponent (line 19) | type PathComponent = { position: Point; facing: Vector; t: number };
function queryPath (line 21) | function queryPath(p: Path, at: number): PathComponent {
function packPathComponent (line 24) | function packPathComponent(p: PathComponent): [number, number, number, n...
function unpackPathComponent (line 27) | function unpackPathComponent(p: [number, number, number, number, number]...
FILE: convex/util/xxhash.ts
constant PRIME32_1 (line 27) | const PRIME32_1 = 2654435761;
constant PRIME32_2 (line 28) | const PRIME32_2 = 2246822519;
constant PRIME32_3 (line 29) | const PRIME32_3 = 3266489917;
constant PRIME32_4 (line 30) | const PRIME32_4 = 668265263;
constant PRIME32_5 (line 31) | const PRIME32_5 = 374761393;
function toUtf8 (line 33) | function toUtf8(text: string): Uint8Array {
function xxHash32 (line 61) | function xxHash32(buffer: Uint8Array | string, seed = 0): number {
FILE: data/convertMap.js
function convertLayerData (line 34) | function convertLayerData(layerData, width, height) {
FILE: data/spritesheets/types.ts
type Frame (line 1) | type Frame = {
type SpritesheetData (line 20) | type SpritesheetData = {
FILE: src/App.tsx
function Home (line 20) | function Home() {
FILE: src/components/Character.tsx
function ViewerIndicator (line 111) | function ViewerIndicator() {
FILE: src/components/ConvexClientProvider.tsx
function convexUrl (line 12) | function convexUrl(): string {
function ConvexClientProvider (line 22) | function ConvexClientProvider({ children }: { children: ReactNode }) {
FILE: src/components/DebugPath.tsx
function DebugPath (line 8) | function DebugPath({ player, tileDim }: { player: Player; tileDim: numbe...
function debugColor (line 34) | function debugColor(_id: string) {
FILE: src/components/DebugTimeManager.tsx
constant MAX_DATA_POINTS (line 5) | const MAX_DATA_POINTS = 10000;
function DebugTimeManager (line 7) | function DebugTimeManager(props: {
constant COLORS (line 152) | const COLORS = (
FILE: src/components/FreezeButton.tsx
function FreezeButton (line 5) | function FreezeButton() {
FILE: src/components/Game.tsx
constant SHOW_DEBUG_UI (line 15) | const SHOW_DEBUG_UI = !!import.meta.env.VITE_SHOW_DEBUG_UI;
function Game (line 17) | function Game() {
FILE: src/components/MessageInput.tsx
function MessageInput (line 10) | function MessageInput({
FILE: src/components/Messages.tsx
function Messages (line 10) | function Messages({
FILE: src/components/PixiViewport.tsx
type ViewportProps (line 9) | type ViewportProps = {
method create (line 22) | create(props: ViewportProps) {
method applyProps (line 47) | applyProps(viewport, oldProps: any, newProps: any) {
FILE: src/components/Player.tsx
type SelectElement (line 14) | type SelectElement = (element?: { kind: 'player'; id: GameId<'players'> ...
FILE: src/components/PlayerDetails.tsx
function PlayerDetails (line 13) | function PlayerDetails({
FILE: src/components/PositionIndicator.tsx
constant ANIMATION_DURATION (line 5) | const ANIMATION_DURATION = 500;
constant RADIUS_TILES (line 6) | const RADIUS_TILES = 0.25;
function PositionIndicator (line 8) | function PositionIndicator(props: {
FILE: src/components/PoweredByConvex.tsx
function PoweredByConvex (line 2) | function PoweredByConvex() {
FILE: src/components/buttons/Button.tsx
function Button (line 4) | function Button(props: {
FILE: src/components/buttons/InteractButton.tsx
function InteractButton (line 13) | function InteractButton() {
FILE: src/components/buttons/LoginButton.tsx
function LoginButton (line 3) | function LoginButton() {
FILE: src/components/buttons/MusicButton.tsx
function MusicButton (line 8) | function MusicButton() {
FILE: src/editor/eutils.js
function download (line 3) | function download(data, filename, type) {
FILE: src/editor/le.js
function tileset_index_from_coords (line 51) | function tileset_index_from_coords(x, y) {
function level_index_from_coords (line 56) | function level_index_from_coords(x, y) {
function tileset_index_from_px (line 62) | function tileset_index_from_px(x, y) {
function level_index_from_px (line 70) | function level_index_from_px(x, y) {
function tileset_coords_from_index (line 76) | function tileset_coords_from_index(index) {
function tileset_px_from_index (line 84) | function tileset_px_from_index(index) {
function sprite_from_px (line 91) | function sprite_from_px(x, y) {
function DragState (line 102) | function DragState() {
class LayerContext (line 116) | class LayerContext {
method constructor (line 118) | constructor(app, pane, num, mod = null) {
method loadFromMapFile (line 161) | loadFromMapFile(mod) {
method drawFilter (line 187) | drawFilter() {
method addTileLevelCoords (line 213) | addTileLevelCoords(x, y, dim, index) {
method addTileLevelPx (line 218) | addTileLevelPx(x, y, index) {
class TilesetContext (line 300) | class TilesetContext {
method constructor (line 302) | constructor(app, mod = g_ctx) {
method addTileSheet (line 351) | addTileSheet(name, sheet){
class CompositeContext (line 369) | class CompositeContext {
method constructor (line 371) | constructor(app) {
function loadAnimatedSpritesFromModule (line 402) | function loadAnimatedSpritesFromModule(mod){
function loadMapFromModuleFinish (line 440) | function loadMapFromModuleFinish(mod) {
function loadMapFromModule (line 456) | function loadMapFromModule(mod) {
function downloadpng (line 462) | function downloadpng(filename) {
function onTilesetDragStart (line 634) | function onTilesetDragStart(e)
function onTilesetDragEnd (line 654) | function onTilesetDragEnd(e)
function onTilesetDrag (line 706) | function onTilesetDrag(e)
function redrawGrid (line 734) | function redrawGrid(pane, redraw = false) {
function centerCompositePane (line 792) | function centerCompositePane(x, y){
function getOldTileValue (line 798) | function getOldTileValue(layer, x, y) {
function centerLayerPanes (line 803) | function centerLayerPanes(x, y){
function onLevelMouseover (line 811) | function onLevelMouseover(e) {
function onLevelMouseOut (line 889) | function onLevelMouseOut(e) {
function onLevelMousemove (line 901) | function onLevelMousemove(e) {
function onCompositeMousedown (line 925) | function onCompositeMousedown(layer, e) {
function levelPlaceNoVariable (line 938) | function levelPlaceNoVariable(layer, e) {
function onLevelPointerDown (line 968) | function onLevelPointerDown(layer, e)
function onLevelDrag (line 988) | function onLevelDrag(layer, e)
function onLevelDragEnd (line 1022) | function onLevelDragEnd(layer, e)
function initPixiApps (line 1160) | function initPixiApps() {
function setGridSize (line 1210) | function setGridSize(size) {
function initRadios (line 1236) | function initRadios() {
function initTilesSync (line 1253) | function initTilesSync(callme) {
function initTiles (line 1330) | function initTiles() {
function init (line 1355) | async function init() {
FILE: src/editor/leconfig.js
constant DEFAULTTILESETPATH (line 1) | const DEFAULTTILESETPATH = "./tilesets/gentle.png";
constant DEFAULTILEDIMX (line 12) | const DEFAULTILEDIMX = 32;
constant DEFAULTILEDIMY (line 13) | const DEFAULTILEDIMY = 32;
constant MAXTILEINDEX (line 21) | const MAXTILEINDEX = leveltilewidth * leveltileheight;
FILE: src/editor/lecontext.js
function ContextSingleton (line 6) | function ContextSingleton() {
FILE: src/editor/lehtmlui.js
function initMainHTMLWindow (line 9) | function initMainHTMLWindow() {
function initCompositePNGLoader (line 41) | function initCompositePNGLoader() {
function initSpriteSheetLoader (line 61) | function initSpriteSheetLoader() {
function initTilesetLoader (line 81) | function initTilesetLoader(callme) {
function doimport (line 99) | function doimport (str) {
function initLevelLoader (line 112) | function initLevelLoader(callme) {
FILE: src/editor/mapfile.js
function generate_preamble (line 7) | function generate_preamble() {
function write_map_file (line 25) | function write_map_file(bg_tiles_0, bg_tiles_1, obj_tiles_1, obj_tiles_2...
function generate_level_file (line 101) | function generate_level_file() {
FILE: src/editor/se.js
function tileset_index_from_coords (line 40) | function tileset_index_from_coords(x, y) {
function level_index_from_coords (line 45) | function level_index_from_coords(x, y) {
function tileset_index_from_px (line 51) | function tileset_index_from_px(x, y) {
function level_index_from_px (line 59) | function level_index_from_px(x, y) {
function tileset_coords_from_index (line 65) | function tileset_coords_from_index(index) {
function tileset_px_from_index (line 73) | function tileset_px_from_index(index) {
function sprite_from_px (line 80) | function sprite_from_px(x, y) {
function DragState (line 101) | function DragState() {
class LayerContext (line 115) | class LayerContext {
method constructor (line 117) | constructor(app, pane, num, mod = null) {
method loadFromMapFile (line 159) | loadFromMapFile(mod) {
method drawFilter (line 185) | drawFilter() {
method addTileLevelCoords (line 211) | addTileLevelCoords(x, y, dim, index) {
method deleteFromIndex (line 217) | deleteFromIndex(index) {
method addTileLevelPx (line 241) | addTileLevelPx(x, y, index) {
method updateAnimatedTiles (line 289) | updateAnimatedTiles() {
class TilesetContext (line 314) | class TilesetContext {
method constructor (line 316) | constructor(app, mod = g_ctx) {
class CompositeContext (line 359) | class CompositeContext {
method constructor (line 361) | constructor(app) {
function doimport (line 393) | function doimport (str) {
function resetPanes (line 406) | function resetPanes() {
function downloadpng (line 426) | function downloadpng(filename) {
function onTilesetDragStart (line 547) | function onTilesetDragStart(e)
function onTilesetDragEnd (line 567) | function onTilesetDragEnd(e)
function onTilesetDrag (line 617) | function onTilesetDrag(e)
function redrawGridPane (line 645) | function redrawGridPane(pane) {
function redrawGrid (line 679) | function redrawGrid() {
function centerCompositePane (line 690) | function centerCompositePane(x, y){
function centerLayerPanes (line 696) | function centerLayerPanes(x, y){
function onLevelMouseover (line 704) | function onLevelMouseover(e) {
function onLevelMouseOut (line 767) | function onLevelMouseOut(e) {
function onLevelMousemove (line 775) | function onLevelMousemove(e) {
function onCompositeMousedown (line 799) | function onCompositeMousedown(layer, e) {
function levelPlaceNoVariable (line 812) | function levelPlaceNoVariable(layer, e) {
function onLevelPointerDown (line 840) | function onLevelPointerDown(layer, e)
function onLevelDrag (line 860) | function onLevelDrag(layer, e)
function onLevelCreateAnimatedSprite (line 893) | function onLevelCreateAnimatedSprite(row) {
function onLevelDragEnd (line 899) | function onLevelDragEnd(layer, e)
function initPixiApps (line 1019) | function initPixiApps() {
function initLevelLoader (line 1075) | function initLevelLoader() {
function setGridSize (line 1100) | function setGridSize(size) {
function initRadios (line 1124) | function initRadios() {
function initTiles (line 1172) | function initTiles() {
function newTilesetFromFile (line 1197) | function newTilesetFromFile(){
function init (line 1202) | async function init() {
FILE: src/editor/seconfig.js
constant DEFAULTTILESETPATH (line 13) | const DEFAULTTILESETPATH = "./spritesheets/tall.png";
constant DEFAULTILEDIMX (line 15) | const DEFAULTILEDIMX = 16;
constant DEFAULTILEDIMY (line 16) | const DEFAULTILEDIMY = 16;
constant MAXTILEINDEX (line 34) | const MAXTILEINDEX = leveltilewidth * leveltileheight;
FILE: src/editor/secontext.js
function ContextSingleton (line 6) | function ContextSingleton() {
FILE: src/editor/sehtmlui.js
function initMainHTMLWindow (line 9) | function initMainHTMLWindow() {
function initCompositePNGLoader (line 33) | function initCompositePNGLoader() {
function initTilesetLoader (line 52) | function initTilesetLoader(callme) {
FILE: src/editor/spritefile.js
function generate_preamble (line 6) | function generate_preamble() {
function download (line 14) | function download(data, filename, type) {
function generate_sprite_file (line 32) | function generate_sprite_file() {
FILE: src/editor/undo.js
constant UNDO_STAX_MAX_LEN (line 1) | const UNDO_STAX_MAX_LEN = 16
function undo_mark_task_start (line 6) | function undo_mark_task_start(layer) {
function undo_add_index_to_task (line 11) | function undo_add_index_to_task(tileindex, oldValue) {
function undo_mark_task_end (line 15) | function undo_mark_task_end() {
function undo_add_single_index_as_task (line 23) | function undo_add_single_index_as_task(layer, tileindex, oldValue) {
function undo_pop (line 29) | function undo_pop() {
FILE: src/hooks/sendInput.ts
function waitForInput (line 6) | async function waitForInput(convex: ConvexReactClient, inputId: Id<'inpu...
function useSendInput (line 42) | function useSendInput<Name extends keyof Inputs>(
FILE: src/hooks/serverGame.ts
type ServerGame (line 12) | type ServerGame = {
function useServerGame (line 21) | function useServerGame(worldId: Id<'worlds'> | undefined): ServerGame | ...
FILE: src/hooks/useHistoricalTime.ts
function useHistoricalTime (line 4) | function useHistoricalTime(engineStatus?: Doc<'engines'>) {
type ServerTimeInterval (line 24) | type ServerTimeInterval = {
class HistoricalTimeManager (line 29) | class HistoricalTimeManager {
method receive (line 37) | receive(engineStatus: Doc<'engines'>) {
method historicalServerTime (line 59) | historicalServerTime(clientNow: number): number | undefined {
method bufferHealth (line 125) | bufferHealth(): number {
method clockSkew (line 133) | clockSkew(): number {
constant MAX_SERVER_BUFFER_AGE (line 141) | const MAX_SERVER_BUFFER_AGE = 1500;
constant SOFT_MAX_SERVER_BUFFER_AGE (line 142) | const SOFT_MAX_SERVER_BUFFER_AGE = 1250;
constant SOFT_MIN_SERVER_BUFFER_AGE (line 143) | const SOFT_MIN_SERVER_BUFFER_AGE = 250;
FILE: src/hooks/useHistoricalValue.ts
function useHistoricalValue (line 4) | function useHistoricalValue<T extends Record<string, number>>(
class HistoryManager (line 33) | class HistoryManager {
method receive (line 36) | receive(sampleRecord: Record<string, History>) {
method query (line 50) | query(historicalTime: number): Record<string, number> {
FILE: src/hooks/useWorldHeartbeat.ts
function useWorldHeartbeat (line 6) | function useWorldHeartbeat() {
FILE: src/toasts.ts
function toastOnError (line 3) | async function toastOnError<T>(promise: Promise<T>): Promise<T> {
Condensed preview — 159 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,125K chars).
[
{
"path": ".dockerignore",
"chars": 533,
"preview": "# flyctl launch added from .gitignore\n# See https://help.github.com/articles/ignoring-files/ for more about ignoring fil"
},
{
"path": ".eslintignore",
"chars": 101,
"preview": "webpack*\n.eslintrc.js\nnext.config.js\ntailwind.config.js\npostcss.config.js\nconvex/_generated/*\ndist/*\n"
},
{
"path": ".eslintrc.js",
"chars": 819,
"preview": "export default {\n parser: '@typescript-eslint/parser', // Specifies the ESLint parser\n plugins: ['@typescript-eslint']"
},
{
"path": ".gitignore",
"chars": 451,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\ngame"
},
{
"path": ".prettierrc",
"chars": 141,
"preview": "{\n \"trailingComma\": \"all\",\n \"singleQuote\": true,\n \"bracketSpacing\": true,\n \"tabWidth\": 2,\n \"proseWrap\": \"always\",\n "
},
{
"path": ".vercelignore",
"chars": 28,
"preview": "convex-local*\nconvex_local*\n"
},
{
"path": ".vscode/convex.code-snippets",
"chars": 2285,
"preview": "{\n \"Convex Imports\": {\n \"prefix\": \"convex:imports\",\n \"body\": [\n \"import { v } from \\\"convex/values\\\";\",\n "
},
{
"path": ".vscode/settings.json",
"chars": 584,
"preview": "{\n \"editor.formatOnSave\": true,\n \"editor.tabSize\": 2,\n \"[html]\": {\n \"editor.defaultFormatter\": \"esbenp.prettier-vs"
},
{
"path": "ARCHITECTURE.md",
"chars": 18712,
"preview": "# Architecture\n\nThis documents dives into the high-level architecture of AI Town and its different layers. We'll\nfirst s"
},
{
"path": "Dockerfile",
"chars": 1056,
"preview": "# Use an Ubuntu base image\nFROM ubuntu:22.04\n\n# Install dependencies\nRUN apt-get update && \\\n apt-get install -y \\\n "
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2023 a16z-infra\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 25606,
"preview": "# AI Town 🏠💻💌\n\n[Live Demo](https://www.convex.dev/ai-town)\n\n[Join our community Discord: AI Stack Devs](https://discord."
},
{
"path": "convex/_generated/api.d.ts",
"chars": 5110,
"preview": "/* eslint-disable */\n/**\n * Generated `api` utility.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, ru"
},
{
"path": "convex/_generated/api.js",
"chars": 414,
"preview": "/* eslint-disable */\n/**\n * Generated `api` utility.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, ru"
},
{
"path": "convex/_generated/dataModel.d.ts",
"chars": 1725,
"preview": "/* eslint-disable */\n/**\n * Generated data model types.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate,"
},
{
"path": "convex/_generated/server.d.ts",
"chars": 5539,
"preview": "/* eslint-disable */\n/**\n * Generated utilities for implementing server-side Convex query and mutation functions.\n *\n * "
},
{
"path": "convex/_generated/server.js",
"chars": 3453,
"preview": "/* eslint-disable */\n/**\n * Generated utilities for implementing server-side Convex query and mutation functions.\n *\n * "
},
{
"path": "convex/agent/conversation.ts",
"chars": 11562,
"preview": "import { v } from 'convex/values';\nimport { Id } from '../_generated/dataModel';\nimport { ActionCtx, internalQuery } fro"
},
{
"path": "convex/agent/embeddingsCache.ts",
"chars": 3451,
"preview": "import { v } from 'convex/values';\nimport { ActionCtx, internalMutation, internalQuery } from '../_generated/server';\nim"
},
{
"path": "convex/agent/memory.ts",
"chars": 14974,
"preview": "import { v } from 'convex/values';\nimport { ActionCtx, DatabaseReader, internalMutation, internalQuery } from '../_gener"
},
{
"path": "convex/agent/schema.ts",
"chars": 1497,
"preview": "import { v } from 'convex/values';\nimport { playerId, conversationId } from '../aiTown/ids';\nimport { defineTable } from"
},
{
"path": "convex/aiTown/agent.ts",
"chars": 13440,
"preview": "import { ObjectType, v } from 'convex/values';\nimport { GameId, parseGameId } from './ids';\nimport { agentId, conversati"
},
{
"path": "convex/aiTown/agentDescription.ts",
"chars": 743,
"preview": "import { ObjectType, v } from 'convex/values';\nimport { GameId, agentId, parseGameId } from './ids';\n\nexport class Agent"
},
{
"path": "convex/aiTown/agentInputs.ts",
"chars": 4942,
"preview": "import { v } from 'convex/values';\nimport { agentId, conversationId, parseGameId } from './ids';\nimport { Player, activi"
},
{
"path": "convex/aiTown/agentOperations.ts",
"chars": 5765,
"preview": "import { v } from 'convex/values';\nimport { internalAction } from '../_generated/server';\nimport { WorldMap, serializedW"
},
{
"path": "convex/aiTown/conversation.ts",
"chars": 13834,
"preview": "import { ObjectType, v } from 'convex/values';\nimport { GameId, parseGameId } from './ids';\nimport { conversationId, pla"
},
{
"path": "convex/aiTown/conversationMembership.ts",
"chars": 1088,
"preview": "import { ObjectType, v } from 'convex/values';\nimport { GameId, parseGameId, playerId } from './ids';\n\nexport const seri"
},
{
"path": "convex/aiTown/game.ts",
"chars": 12388,
"preview": "import { Infer, v } from 'convex/values';\nimport { Doc, Id } from '../_generated/dataModel';\nimport {\n ActionCtx,\n Dat"
},
{
"path": "convex/aiTown/ids.ts",
"chars": 1143,
"preview": "import { v } from 'convex/values';\n\nconst IdShortCodes = { agents: 'a', conversations: 'c', players: 'p', operations: 'o"
},
{
"path": "convex/aiTown/inputHandler.ts",
"chars": 333,
"preview": "import { ObjectType, PropertyValidators, Value } from 'convex/values';\nimport type { Game } from './game';\n\nexport funct"
},
{
"path": "convex/aiTown/inputs.ts",
"chars": 946,
"preview": "import { ObjectType } from 'convex/values';\nimport { playerInputs } from './player';\nimport { conversationInputs } from "
},
{
"path": "convex/aiTown/insertInput.ts",
"chars": 678,
"preview": "import { MutationCtx } from '../_generated/server';\nimport { Id } from '../_generated/dataModel';\nimport { engineInsertI"
},
{
"path": "convex/aiTown/location.ts",
"chars": 675,
"preview": "import { FieldConfig } from '../engine/historicalObject';\nimport { Player } from './player';\n\nexport type Location = {\n "
},
{
"path": "convex/aiTown/main.ts",
"chars": 4827,
"preview": "import { ConvexError, v } from 'convex/values';\nimport { DatabaseReader, MutationCtx, internalAction, mutation, query } "
},
{
"path": "convex/aiTown/movement.ts",
"chars": 6174,
"preview": "import { movementSpeed } from '../../data/characters';\nimport { COLLISION_THRESHOLD } from '../constants';\nimport { comp"
},
{
"path": "convex/aiTown/player.ts",
"chars": 8671,
"preview": "import { Infer, ObjectType, v } from 'convex/values';\nimport { Point, Vector, path, point, vector } from '../util/types'"
},
{
"path": "convex/aiTown/playerDescription.ts",
"chars": 920,
"preview": "import { ObjectType, v } from 'convex/values';\nimport { GameId, parseGameId, playerId } from './ids';\n\nexport const seri"
},
{
"path": "convex/aiTown/schema.ts",
"chars": 3354,
"preview": "import { v } from 'convex/values';\nimport { defineTable } from 'convex/server';\nimport { serializedPlayer } from './play"
},
{
"path": "convex/aiTown/world.ts",
"chars": 2260,
"preview": "import { ObjectType, v } from 'convex/values';\nimport { Conversation, serializedConversation } from './conversation';\nim"
},
{
"path": "convex/aiTown/worldMap.ts",
"chars": 1978,
"preview": "import { Infer, ObjectType, v } from 'convex/values';\n\n// `layer[position.x][position.y]` is the tileIndex or -1 if empt"
},
{
"path": "convex/constants.ts",
"chars": 2792,
"preview": "export const ACTION_TIMEOUT = 120_000; // more time for local dev\n// export const ACTION_TIMEOUT = 60_000;// normally fi"
},
{
"path": "convex/crons.ts",
"chars": 2948,
"preview": "import { cronJobs } from 'convex/server';\nimport { DELETE_BATCH_SIZE, IDLE_WORLD_TIMEOUT, VACUUM_MAX_AGE } from './const"
},
{
"path": "convex/engine/abstractGame.ts",
"chars": 5955,
"preview": "import { ConvexError, Infer, Value, v } from 'convex/values';\nimport { Doc, Id } from '../_generated/dataModel';\nimport "
},
{
"path": "convex/engine/historicalObject.test.ts",
"chars": 1723,
"preview": "import { History, packSampleRecord, unpackSampleRecord } from './historicalObject';\n\ndescribe('HistoricalObject', () => "
},
{
"path": "convex/engine/historicalObject.ts",
"chars": 11371,
"preview": "import { xxHash32 } from '../util/xxhash';\nimport { compressSigned, uncompressSigned } from '../util/FastIntegerCompress"
},
{
"path": "convex/engine/schema.ts",
"chars": 1767,
"preview": "import { defineTable } from 'convex/server';\nimport { Infer, v } from 'convex/values';\n\nconst input = v.object({\n // In"
},
{
"path": "convex/http.ts",
"chars": 243,
"preview": "import { httpRouter } from 'convex/server';\nimport { handleReplicateWebhook } from './music';\n\nconst http = httpRouter()"
},
{
"path": "convex/init.ts",
"chars": 3325,
"preview": "import { v } from 'convex/values';\nimport { internal } from './_generated/api';\nimport { DatabaseReader, MutationCtx, mu"
},
{
"path": "convex/messages.ts",
"chars": 1584,
"preview": "import { v } from 'convex/values';\nimport { mutation, query } from './_generated/server';\nimport { insertInput } from '."
},
{
"path": "convex/music.ts",
"chars": 4667,
"preview": "import { v } from 'convex/values';\nimport { query, internalMutation } from './_generated/server';\nimport Replicate, { We"
},
{
"path": "convex/schema.ts",
"chars": 794,
"preview": "import { defineSchema, defineTable } from 'convex/server';\nimport { v } from 'convex/values';\nimport { agentTables } fro"
},
{
"path": "convex/testing.ts",
"chars": 6173,
"preview": "import { Id, TableNames } from './_generated/dataModel';\nimport { internal } from './_generated/api';\nimport {\n Databas"
},
{
"path": "convex/util/FastIntegerCompression.ts",
"chars": 6551,
"preview": "/**\n * FastIntegerCompression.js : a fast integer compression library in JavaScript.\n * From https://github.com/lemire/F"
},
{
"path": "convex/util/assertNever.ts",
"chars": 224,
"preview": "// From https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-exhaustiveness-checking\nexport "
},
{
"path": "convex/util/asyncMap.test.ts",
"chars": 478,
"preview": "import { asyncMap } from './asyncMap';\n\ndescribe('asyncMap', () => {\n it('should map over a list asynchronously', async"
},
{
"path": "convex/util/asyncMap.ts",
"chars": 555,
"preview": "/**\n * asyncMap returns the results of applying an async function over an list.\n *\n * @param list - Iterable object of i"
},
{
"path": "convex/util/compression.test.ts",
"chars": 3564,
"preview": "import {\n deltaDecode,\n deltaEncode,\n quantize,\n runLengthDecode,\n runLengthEncode,\n unquantize,\n} from './compres"
},
{
"path": "convex/util/compression.ts",
"chars": 1674,
"preview": "export function quantize(values: number[], precision: number) {\n const factor = 1 << precision;\n return values.map((v)"
},
{
"path": "convex/util/geometry.test.ts",
"chars": 8944,
"preview": "import { compressPath, distance, manhattanDistance, normalize, orientationDegrees, pathOverlaps, pathPosition, pointsEqu"
},
{
"path": "convex/util/geometry.ts",
"chars": 4071,
"preview": "import { Path, PathComponent, Point, Vector, packPathComponent, queryPath } from './types';\n\nexport function distance(p0"
},
{
"path": "convex/util/isSimpleObject.ts",
"chars": 466,
"preview": "export function isSimpleObject(value: unknown) {\n const isObject = typeof value === 'object';\n const prototype = Objec"
},
{
"path": "convex/util/llm.ts",
"chars": 23624,
"preview": "// That's right! No imports and no dependencies 🤯\n\nconst OPENAI_EMBEDDING_DIMENSION = 1536;\nconst TOGETHER_EMBEDDING_DIM"
},
{
"path": "convex/util/minheap.test.ts",
"chars": 1736,
"preview": "import { MinHeap } from './minheap';\n\ndescribe('MinHeap', () => {\n const compareNumbers = (a: number, b: number): boole"
},
{
"path": "convex/util/minheap.ts",
"chars": 1264,
"preview": "// Basic 1-indexed minheap implementation\nexport function MinHeap<T>(compare: (a: T, b: T) => boolean) {\n const tree = "
},
{
"path": "convex/util/object.ts",
"chars": 601,
"preview": "export function parseMap<Id, Serialized, Parsed>(\n records: Serialized[],\n constructor: new (r: Serialized) => Parsed,"
},
{
"path": "convex/util/sleep.ts",
"chars": 104,
"preview": "export async function sleep(ms: number) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"
},
{
"path": "convex/util/types.test.ts",
"chars": 1153,
"preview": "import { Path, PathComponent, packPathComponent, queryPath, unpackPathComponent } from \"./types\";\n\ndescribe('queryPath',"
},
{
"path": "convex/util/types.ts",
"chars": 984,
"preview": "import { Infer, v } from 'convex/values';\n\nexport const point = v.object({\n x: v.number(),\n y: v.number(),\n});\nexport "
},
{
"path": "convex/util/xxhash.ts",
"chars": 8216,
"preview": "/*\nFrom https://github.com/Jason3S/xxhash\n\nMIT License\n\nCopyright (c) 2019 Jason Dent\n\nPermission is hereby granted, fre"
},
{
"path": "convex/world.ts",
"chars": 8076,
"preview": "import { ConvexError, v } from 'convex/values';\nimport { internalMutation, mutation, query } from './_generated/server';"
},
{
"path": "data/animations/campfire.json",
"chars": 988,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":0,\"w\":32,\"h\":32},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"sprit"
},
{
"path": "data/animations/gentlesparkle.json",
"chars": 791,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":0,\"w\":32,\"h\":32},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"sprit"
},
{
"path": "data/animations/gentlesplash.json",
"chars": 1419,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":192,\"w\":32,\"h\":64},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"spr"
},
{
"path": "data/animations/gentlewaterfall.json",
"chars": 1413,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":32,\"w\":32,\"h\":96},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"spri"
},
{
"path": "data/animations/windmill.json",
"chars": 2407,
"preview": "{\n \"frames\": {\n \"pixels_large1.png\": {\n \"frame\": { \"x\": 0, \"y\": 0, \"w\": 208, \"h\": 208 },\n \"rotated\": false"
},
{
"path": "data/characters.ts",
"chars": 4872,
"preview": "import { data as f1SpritesheetData } from './spritesheets/f1';\nimport { data as f2SpritesheetData } from './spritesheets"
},
{
"path": "data/convertMap.js",
"chars": 2722,
"preview": "import fs from 'fs';\nimport process from 'process';\n\n// Path to the JSON file containing the map data\nconst mapDataPath "
},
{
"path": "data/gentle.js",
"chars": 73002,
"preview": "// Map generated by assettool.js [Wed Oct 18 2023 21:07:27 GMT-0700 (Pacific Daylight Time)]\n\nexport const tilesetpath ="
},
{
"path": "data/spritesheets/f1.ts",
"chars": 1981,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n left: {\n frame: "
},
{
"path": "data/spritesheets/f2.ts",
"chars": 1993,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/f3.ts",
"chars": 1997,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/f4.ts",
"chars": 1997,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/f5.ts",
"chars": 1996,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/f6.ts",
"chars": 2008,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/f7.ts",
"chars": 2012,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/f8.ts",
"chars": 2012,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n down: {\n frame: "
},
{
"path": "data/spritesheets/p1.ts",
"chars": 1513,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n left: {\n frame: "
},
{
"path": "data/spritesheets/p2.ts",
"chars": 1522,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n left: {\n frame: "
},
{
"path": "data/spritesheets/p3.ts",
"chars": 1522,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n left: {\n frame: "
},
{
"path": "data/spritesheets/player.ts",
"chars": 1515,
"preview": "import { SpritesheetData } from './types';\n\nexport const data: SpritesheetData = {\n frames: {\n left: {\n frame: "
},
{
"path": "data/spritesheets/types.ts",
"chars": 395,
"preview": "export type Frame = {\n frame: {\n x: number;\n y: number;\n w: number;\n h: number;\n };\n rotated?: boolean;\n "
},
{
"path": "docker-compose.yml",
"chars": 1391,
"preview": "version: '3.8'\n\nservices:\n frontend:\n build: .\n ports:\n - '5173:5173'\n volumes:\n - .:/usr/src/app\n "
},
{
"path": "fly/README.md",
"chars": 4558,
"preview": "# Hosting AI Town on Fly.io\n\nFly.io makes it easy to deploy containers to the cloud.\n\n## Prerequisites\n\n- Fly.io account"
},
{
"path": "fly/backend/fly.toml",
"chars": 767,
"preview": "# fly.toml app configuration file generated for convex-backend on 2025-02-12T15:17:28-08:00\n#\n# See https://fly.io/docs/"
},
{
"path": "fly/dashboard/fly.toml",
"chars": 569,
"preview": "# fly.toml app configuration file generated for convex-dashboard on 2025-02-12T15:24:01-08:00\n#\n# See https://fly.io/doc"
},
{
"path": "index.html",
"chars": 581,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" type=\"image/x-icon\" sizes=\"a"
},
{
"path": "jest.config.ts",
"chars": 168,
"preview": "import type { JestConfigWithTsJest } from 'ts-jest';\n\nconst jestConfig: JestConfigWithTsJest = {\n preset: 'ts-jest/pres"
},
{
"path": "package.json",
"chars": 1860,
"preview": "{\n \"name\": \"ai-town\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"npm-run-all --parallel dev:ba"
},
{
"path": "postcss.config.js",
"chars": 81,
"preview": "export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n"
},
{
"path": "public/assets/tilemap.json",
"chars": 120109,
"preview": "{\n \"compressionlevel\": -1,\n \"height\": 40,\n \"infinite\": false,\n \"layers\": [\n {\n \"data\": [\n 8201, 8202,"
},
{
"path": "src/App.tsx",
"chars": 5583,
"preview": "import Game from './components/Game.tsx';\n\nimport { ToastContainer } from 'react-toastify';\nimport a16zImg from '../asse"
},
{
"path": "src/components/Character.tsx",
"chars": 3381,
"preview": "import { BaseTexture, ISpritesheetData, Spritesheet } from 'pixi.js';\nimport { useState, useEffect, useRef, useCallback "
},
{
"path": "src/components/ConvexClientProvider.tsx",
"chars": 1121,
"preview": "import { ReactNode } from 'react';\nimport { ConvexReactClient, ConvexProvider } from 'convex/react';\n// import { ConvexP"
},
{
"path": "src/components/DebugPath.tsx",
"chars": 1150,
"preview": "import { Graphics } from '@pixi/react';\nimport { Graphics as PixiGraphics } from 'pixi.js';\nimport { useCallback } from "
},
{
"path": "src/components/DebugTimeManager.tsx",
"chars": 4681,
"preview": "import { HistoricalTimeManager } from '@/hooks/useHistoricalTime';\nimport { useEffect, useLayoutEffect, useRef, useState"
},
{
"path": "src/components/FreezeButton.tsx",
"chars": 1029,
"preview": "import { useMutation, useQuery } from 'convex/react';\nimport { api } from '../../convex/_generated/api';\nimport Button f"
},
{
"path": "src/components/Game.tsx",
"chars": 3314,
"preview": "import { useRef, useState } from 'react';\nimport PixiGame from './PixiGame.tsx';\n\nimport { useElementSize } from 'usehoo"
},
{
"path": "src/components/MessageInput.tsx",
"chars": 2836,
"preview": "import clsx from 'clsx';\nimport { useMutation, useQuery } from 'convex/react';\nimport { KeyboardEvent, useRef, useState "
},
{
"path": "src/components/Messages.tsx",
"chars": 5973,
"preview": "import clsx from 'clsx';\nimport { Doc, Id } from '../../convex/_generated/dataModel';\nimport { useQuery } from 'convex/r"
},
{
"path": "src/components/PixiGame.tsx",
"chars": 4472,
"preview": "import * as PIXI from 'pixi.js';\nimport { useApp } from '@pixi/react';\nimport { Player, SelectElement } from './Player.t"
},
{
"path": "src/components/PixiStaticMap.tsx",
"chars": 4514,
"preview": "import { PixiComponent, applyDefaultProps } from '@pixi/react';\nimport * as PIXI from 'pixi.js';\nimport { AnimatedSprite"
},
{
"path": "src/components/PixiViewport.tsx",
"chars": 1777,
"preview": "// Based on https://codepen.io/inlet/pen/yLVmPWv.\n// Copyright (c) 2018 Patrick Brouwer, distributed under the MIT licen"
},
{
"path": "src/components/Player.tsx",
"chars": 2947,
"preview": "import { Character } from './Character.tsx';\nimport { orientationDegrees } from '../../convex/util/geometry.ts';\nimport "
},
{
"path": "src/components/PlayerDetails.tsx",
"chars": 8920,
"preview": "import { useQuery } from 'convex/react';\nimport { api } from '../../convex/_generated/api';\nimport { Id } from '../../co"
},
{
"path": "src/components/PositionIndicator.tsx",
"chars": 810,
"preview": "import { useCallback, useState } from 'react';\nimport { Graphics } from '@pixi/react';\nimport { Graphics as PixiGraphics"
},
{
"path": "src/components/PoweredByConvex.tsx",
"chars": 4288,
"preview": "import bannerBg from '../../assets/convex-bg.webp';\nexport default function PoweredByConvex() {\n return (\n <a\n "
},
{
"path": "src/components/buttons/Button.tsx",
"chars": 789,
"preview": "import clsx from 'clsx';\nimport { MouseEventHandler, ReactNode } from 'react';\n\nexport default function Button(props: {\n"
},
{
"path": "src/components/buttons/InteractButton.tsx",
"chars": 2282,
"preview": "import Button from './Button';\nimport { toast } from 'react-toastify';\nimport interactImg from '../../../assets/interact"
},
{
"path": "src/components/buttons/LoginButton.tsx",
"chars": 319,
"preview": "import { SignInButton } from '@clerk/clerk-react';\n\nexport default function LoginButton() {\n return (\n <SignInButton"
},
{
"path": "src/components/buttons/MusicButton.tsx",
"chars": 1363,
"preview": "import { useCallback, useEffect, useState } from 'react';\nimport volumeImg from '../../../assets/volume.svg';\nimport { s"
},
{
"path": "src/editor/README.md",
"chars": 2427,
"preview": "# Level Editor\n\n## Setup\n\n1. Run `npm run le` to start the level editor (localhost:5174)\n2. Import a map composite file "
},
{
"path": "src/editor/campfire.json",
"chars": 988,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":0,\"w\":32,\"h\":32},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"sprit"
},
{
"path": "src/editor/eutils.js",
"chars": 616,
"preview": "\n// Function to download data to a file\nexport function download(data, filename, type) {\n var file = new Blob([data],"
},
{
"path": "src/editor/gentlesparkle.json",
"chars": 791,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":0,\"w\":32,\"h\":32},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"sprit"
},
{
"path": "src/editor/gentlesplash.json",
"chars": 1419,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":192,\"w\":32,\"h\":64},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"spr"
},
{
"path": "src/editor/gentlewaterfall.json",
"chars": 1413,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":32,\"w\":32,\"h\":96},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"spri"
},
{
"path": "src/editor/index.html",
"chars": 116,
"preview": "<html>\n<head>\n<body>\n<a href=\"./le.html\">Level editer</a><br>\n<a href=\"./se.html\">Sprite editer</a>\n</body>\n</html>\n"
},
{
"path": "src/editor/le.html",
"chars": 3417,
"preview": "<html>\n<head>\n<style>\n table, th, td {\n border: 1px solid black;\n border-radius: 10px;\n }\n </style>\n"
},
{
"path": "src/editor/le.js",
"chars": 50175,
"preview": "// --\n// Simple level editer. \n//\n// TODO:\n// -- right now if plaxing a sprite, will place based on selected tiles. So "
},
{
"path": "src/editor/leconfig.js",
"chars": 1289,
"preview": "export const DEFAULTTILESETPATH = \"./tilesets/gentle.png\";\n//export const DEFAULTTILESETPATH = \"./tilesets/magecity.png\""
},
{
"path": "src/editor/lecontext.js",
"chars": 1286,
"preview": "import * as PIXI from 'pixi.js'\nimport * as CONFIG from './leconfig.js'\n\nvar ContextCreate = (function(){\n\n function "
},
{
"path": "src/editor/lehtmlui.js",
"chars": 4394,
"preview": "import * as PIXI from 'pixi.js'\nimport { g_ctx } from './lecontext.js' // global context\nimport * as CONFIG from './lec"
},
{
"path": "src/editor/mapfile.js",
"chars": 7965,
"preview": "import * as CONFIG from './leconfig.js' \nimport * as UTIL from './eutils.js'\nimport { g_ctx } from './lecontext.js' /"
},
{
"path": "src/editor/maps/gentle-full.js",
"chars": 71742,
"preview": "// Map generated by assettool.js [Mon Oct 02 2023 00:20:59 GMT-0700 (Pacific Daylight Time)]\n\nexport const tilesetpath ="
},
{
"path": "src/editor/maps/gentle.js",
"chars": 72907,
"preview": "// Map generated by assettool.js [Wed Oct 18 2023 21:07:27 GMT-0700 (Pacific Daylight Time)]\n\nexport const tilesetpath ="
},
{
"path": "src/editor/maps/gentleanim.js",
"chars": 69611,
"preview": "// Map generated by assettool.js [Sat Sep 30 2023 23:42:21 GMT-0700 (Pacific Daylight Time)]\n\nexport const tilesetpath ="
},
{
"path": "src/editor/maps/mage3.js",
"chars": 50804,
"preview": "// Map generated by assettool.jsThu Aug 31 2023 00:08:34 GMT-0700 (Pacific Daylight Time)\n\nexport const tilesetpath = \"."
},
{
"path": "src/editor/maps/serene.js",
"chars": 60646,
"preview": "// Map generated by assettool.js [Tue Sep 19 2023 09:13:08 GMT-0700 (Pacific Daylight Time)]\n\nexport const tilesetpath ="
},
{
"path": "src/editor/se.html",
"chars": 3259,
"preview": "<html>\n<head>\n<style>\n table, th, td {\n border: 1px solid black;\n border-radius: 10px;\n }\n </style>\n"
},
{
"path": "src/editor/se.js",
"chars": 43845,
"preview": "// --\n// Simple level editer. \n//\n// TODO: \n// - <esc> clear selected_tiles\n// \n// Done:\n// - Delete tiles\n// - move "
},
{
"path": "src/editor/seconfig.js",
"chars": 1704,
"preview": "//export const DEFAULTTILESETPATH = \"./spritesheets/women.png\";\n//export const DEFAULTILEDIMX = 32; // px\n//export const"
},
{
"path": "src/editor/secontext.js",
"chars": 1231,
"preview": "import * as PIXI from 'pixi.js'\nimport * as CONFIG from './seconfig.js'\n\nvar ContextCreate = (function(){\n\n function "
},
{
"path": "src/editor/sehtmlui.js",
"chars": 2639,
"preview": "import * as PIXI from 'pixi.js'\nimport { g_ctx } from './secontext.js' // global context\nimport * as CONFIG from './sec"
},
{
"path": "src/editor/spritefile.js",
"chars": 3504,
"preview": "import * as CONFIG from './seconfig.js' \nimport * as UTIL from './eutils.js'\nimport { g_ctx } from './secontext.js' // "
},
{
"path": "src/editor/undo.js",
"chars": 731,
"preview": "const UNDO_STAX_MAX_LEN = 16\n\nlet undo_stack = [];\nlet undoqueu = [];\n\nexport function undo_mark_task_start(layer) {\n "
},
{
"path": "src/editor/windmill.json",
"chars": 1858,
"preview": "{\"frames\": {\n\n\"pixels_large1.png\":\n{\n\t\"frame\": {\"x\":0,\"y\":0,\"w\":208,\"h\":208},\n\t\"rotated\": false,\n\t\"trimmed\": true,\n\t\"spr"
},
{
"path": "src/hooks/sendInput.ts",
"chars": 1644,
"preview": "import { ConvexReactClient, useConvex } from 'convex/react';\nimport { InputArgs, InputReturnValue, Inputs } from '../../"
},
{
"path": "src/hooks/serverGame.ts",
"chars": 1733,
"preview": "import { GameId } from '../../convex/aiTown/ids.ts';\nimport { AgentDescription } from '../../convex/aiTown/agentDescript"
},
{
"path": "src/hooks/useHistoricalTime.ts",
"chars": 4783,
"preview": "import { Doc } from '../../convex/_generated/dataModel';\nimport { useEffect, useRef, useState } from 'react';\n\nexport fu"
},
{
"path": "src/hooks/useHistoricalValue.ts",
"chars": 2341,
"preview": "import { FieldConfig, History, unpackSampleRecord } from '../../convex/engine/historicalObject';\nimport { useMemo, useRe"
},
{
"path": "src/hooks/useWorldHeartbeat.ts",
"chars": 1148,
"preview": "import { useMutation, useQuery } from 'convex/react';\nimport { useEffect } from 'react';\nimport { api } from '../../conv"
},
{
"path": "src/index.css",
"chars": 4403,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@font-face {\n font-family: 'Upheaval Pro';\n src: url(/asse"
},
{
"path": "src/main.tsx",
"chars": 457,
"preview": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport Home from './App.tsx';\nimport './index.css';\n"
},
{
"path": "src/toasts.ts",
"chars": 228,
"preview": "import { toast } from 'react-toastify';\n\nexport async function toastOnError<T>(promise: Promise<T>): Promise<T> {\n try "
},
{
"path": "src/vite-env.d.ts",
"chars": 38,
"preview": "/// <reference types=\"vite/client\" />\n"
},
{
"path": "tailwind.config.js",
"chars": 724,
"preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}']"
},
{
"path": "tsconfig.json",
"chars": 677,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2015\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n "
},
{
"path": "vercel.json",
"chars": 127,
"preview": "{\n \"framework\": \"vite\",\n \"rewrites\": [\n {\n \"source\": \"/ai-town/:match*\",\n \"destination\": \"/:match*\"\n }"
},
{
"path": "vite.config.ts",
"chars": 282,
"preview": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\n// https://vitejs.dev/config/\nexport def"
}
]
About this extraction
This page contains the full source code of the a16z-infra/ai-town GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 159 files (1.0 MB), approximately 417.8k tokens, and a symbol index with 434 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.