HBOx is a Unity project for generating and replaying AI-driven "Chats": short staged scenes with a cast, location, dialogue, sentiment, timing, and audio. The project started as polbots, but it now acts more like a shared runtime for multiple shows and worlds.
The current project is split into two main layers:
Assets/CoreShared engine code for chat generation, playback, actor control, UI, config loading, external integrations, replay loading, and prompt resolution.Assets/ScenesShow-specific scenes, actors, prefabs, audio, and scene adapters.
At a high level, the pipeline looks like this:
- A source provides an
Idea. Sources include replay folders, Reddit batches, HTTP endpoints, and scene-specific systems like thepolbotssoccer integration. ChatGeneratorturns the idea into aChat. It resolves prompt files fromVault, asks the LLM for topic/cast/location details, then runs attached generator components to fill in dialogue, reactions, voice lines, vibe, memories, and related metadata.ChatManagerqueues and plays the chat. It switches to the correct scene/context, loads staging information, spawns actors, runs intermission hooks, and activates eachChatNodein sequence.ActorControllerand scene components perform the scene. Shared actor subsystems handle animation, face/lips, camera look targets, voice playback, items, subtitles, and scene-specific behavior.
The original project, and still the heaviest scene-specific code layer.
Responsibilities:
- video-call presentation and per-actor cameras
- globe-mode staging and map-driven positioning
- country/flag-driven visuals
- soccer match integration that can emit live ideas back into the chat pipeline
Mostly a content/world pack on top of the shared core. It currently has very little custom C# and relies primarily on shared generation/playback behavior plus its own actors, prefabs, and assets.
Uses the shared core runtime with show-specific scene assets and actor data. No custom scene-side C# currently lives here.
Also uses the shared core runtime with its own cast, prefabs, and assets, with no current scene-side C# layer.
Assets/CoreRuntime systems shared across all shows.Assets/ScenesShow scenes and content.VaultPrompt, input, output, and other text assets used during generation.MemoryCapturesMemory-related data produced by the project.replays-*.txtLocal replay history trackers.reddit-*.txtLocal Reddit history trackers.
The project loads config.json from the executable root path through ConfigManager.
Each config entry must contain a Type field. Only the systems present in your scene/context need to be configured.
Example:
[
{
"Type": "openai",
"ApiUri": "https://api.openai.com",
"ApiKey": "YOUR_OPENAI_API_KEY",
"SlowModel": "gpt-5.4",
"FastModel": "gpt-5.4-mini",
"ModelProfiles": {
"Utility": "gpt-5.4-nano",
"PostProcess": "gpt-5.4-mini",
"Sentiment": "gpt-5.4-nano",
"Dialogue": "gpt-5.4",
"SceneReasoning": "gpt-5.4"
},
"ModelPrices": {
"gpt-5.4": {
"InputPerMillion": 2.50,
"CachedInputPerMillion": 0.25,
"OutputPerMillion": 4.50
},
"gpt-5.4-mini": {
"InputPerMillion": 0.20,
"CachedInputPerMillion": 0.02,
"OutputPerMillion": 1.25
},
"gpt-5.4-nano": {
"InputPerMillion": 0.75,
"CachedInputPerMillion": 0.02,
"OutputPerMillion": 1.25
},
"text-embedding-3-small": {
"InputPerMillion": 0.02,
"CachedInputPerMillion": 0,
"OutputPerMillion": 0
},
"gpt-4o-mini-tts": {
"InputPerMillion": 0.60,
"CachedInputPerMillion": 0,
"OutputPerMillion": 12.00
},
"google-standard-tts": {
"InputPerMillion": 0.000004,
"CachedInputPerMillion": 0,
"OutputPerMillion": 0
}
},
"PersistUsage": true,
"UsageLogPath": "Logs/llm-usage",
"Budgets": {
"DailyUsd": 5.0,
"PerEpisodeUsd": 0.25,
"WarnAtPercent": 80,
"EnableUiWarnings": true,
"EnableDiscordWarnings": false,
"DiscordChannel": "#stream"
},
"UseEmbeddings": false
},
{
"Type": "tts",
"GoogleApiKey": "YOUR_GOOGLE_TTS_KEY",
"OpenAiApiKey": "YOUR_OPENAI_TTS_KEY"
},
{
"Type": "discord",
"AvatarURL": "https://...",
"WebhookURLs": {
"#stream": "YOUR_STREAM_WEBHOOK_URL"
},
"EnableBot": true,
"BotToken": "YOUR_BOT_TOKEN",
"ApplicationId": "YOUR_APP_ID",
"SlashCommandGuildIds": ["YOUR_TEST_GUILD_ID"],
"EnableIdeaCommand": true,
"DefaultDailyIdeaLimit": 3,
"BoosterDailyIdeaLimit": 10,
"BoosterRoleIds": ["BOOSTER_ROLE_ID"]
},
{
"Type": "folder",
"ReplayDirectory": "polbots",
"ReplayRate": 80,
"ReplaysPerBatch": 20,
"MaxReplayAgeInMinutes": 1440
},
{
"Type": "reddit",
"SubReddits": {
"worldnews+anime_titties+todayilearned": "Default",
"AskHistorians+UnitedNations+geopolitics": "History",
"Africa+China+america+australia+europe": "Regional"
},
"MaxPostAgeInHours": 24,
"BatchSize": 20,
"BatchSizeLimit": 20,
"BatchIterations": 1,
"BatchPeriodOffset": "00:00",
"BatchPeriodInMinutes": 480,
"ActiveHoursStart": "00:00",
"ActiveHoursEnd": "23:59",
"EnablePitchGate": true,
"PitchDiscordChannel": "#stream",
"PitchExpirationMinutes": 180,
"PitchAutoApprovalBatchSize": 0,
"PitchMinimumVotesToQueue": 1,
"PitchMemoryMaxChars": 4000,
"PitchContinuityEpisodeLimit": 6,
"PitchContinuityMaxChars": 4000,
"PitchSourceSelftextMaxChars": 1200,
"PitchEvaluatorThreadMaxChars": 1800,
"PitchEvaluatorMemoryMaxChars": 1200,
"PitchEvaluatorContinuityMaxChars": 1200,
"PitchEvaluatorActorLimit": 40,
"MaxDepth": 3,
"TopRoots": 3,
"TopLevelLimit": 30,
"PerLevelChildLimit": 20,
"MaxDialogueLines": 16,
"MaxCharsPerLine": 280,
"RequestSpacingSeconds": 6,
"RateLimitCooldownSeconds": 300,
"MaxRequestAttempts": 3,
"OAuthClientId": "YOUR_REDDIT_APP_CLIENT_ID",
"OAuthClientSecret": "YOUR_REDDIT_APP_CLIENT_SECRET",
"OAuthDeviceId": "DO_NOT_TRACK_THIS_DEVICE",
"OAuthUserAgent": "script:hbox:1.1 (by /u/YOUR_REDDIT_USERNAME)",
"Sort": "confidence"
},
{
"Type": "obs",
"VideosFolder": "C:/Videos/HBOx",
"OBSWebSocketURI": "ws://localhost:4455",
"IsStreaming": false,
"IsRecording": false,
"DoSplitRecording": true,
"OnlyNewEpisodes": true
},
{
"Type": "splash",
"Splashes": [
"Tonight on HBOx",
"Previously on nothing"
],
"TitleDuration": 5.0,
"SplashDuration": 2.0
}
]- Remove a config block entirely to disable that integration.
folderis the active replay loader type registered at runtime.reddit.SubRedditsis a dictionary, not a simple string array.reddit.EnablePitchGatechanges Reddit intake from directRedditSource -> Ideageneration intoRedditSource -> PitchCandidate -> Discord vote -> Idea.reddit.PitchDiscordChannelmust match a configured Discord webhook key, usually#stream.reddit.BatchPeriodOffsetandreddit.BatchPeriodInMinutescontrol Reddit fetch cadence all day;reddit.ActiveHoursStartandreddit.ActiveHoursEndonly gate queueing/autoapproval work.- Pitch votes queue immediately once the live vote score reaches
reddit.PitchApprovalScoreand the total vote count meetsreddit.PitchMinimumVotesToQueue. Remaining posted pitches are resolved after the voting window closes: enough positive votes queue the pitch as anIdea; more thumbs-down rejects it; ties, too few votes, or positive scores below the approval score expire. reddit.PitchExpirationMinutesis how long a posted pitch can receive approval votes before it is treated as stale.reddit.PitchApprovalScoreis the positive vote score required to queue a live or expired pitch.reddit.PitchAutoApprovalBatchSizeis how many top evaluator-approved Reddit pitches can skip Discord voting and queue directly per active Reddit batch slot. Set it to0to require voting for every pitch.reddit.PitchMinimumVotesToQueueis the minimum total vote count required before a positive voted pitch can queue.reddit.PitchMemoryMaxChars,reddit.PitchContinuityEpisodeLimit,reddit.PitchContinuityMaxChars, andreddit.PitchSourceSelftextMaxCharsbound the context used while writing pitch cards.reddit.PitchEvaluatorThreadMaxChars,reddit.PitchEvaluatorMemoryMaxChars,reddit.PitchEvaluatorContinuityMaxChars, andreddit.PitchEvaluatorActorLimitkeep the rejection filter cheap; rejected candidates no longer need to pay for generation-sized context.- Autoapproved pitch candidates are sorted by Reddit comment count weighted most heavily, then Reddit karma, then newest post first.
- Pitch generation uses
Vault/{show}/Prompts/Reddit Source/Pitch Candidate.md, thenVault/{show}/Prompts/Reddit Source/Pitch Evaluator.mdto reject weak or overlong pitches before posting. - Posted pitch cards surface the pitch, cast, source subreddit, Reddit karma/comment counts, and evaluator approval reason; the approved
Ideastill includes the raw Reddit post text and mined thread material for generation context. - In pitch-gate mode, Reddit posts are only written to the local seen history after a pitch is accepted for voting, so evaluator-rejected posts can be reconsidered later.
reddit.RequestSpacingSecondsapplies a shared delay across Reddit listing and thread-mining requests;reddit.RateLimitCooldownSecondsis used before retrying after Reddit returns 429 or a 403 blocked response;reddit.MaxRequestAttemptsbounds those retries.reddit.OAuthClientIdenables app-only OAuth for read-only listing and comment requests. AddOAuthClientSecretfor a confidential/script app, or omit the secret and setOAuthDeviceIdfor an installed-app client. When OAuth is configured, Reddit fetches useoauth.reddit.comwith a cached bearer token.reddit.OAuthUserAgentshould identify the app and Reddit username that owns the client, for examplescript:hbox:1.1 (by /u/yourname).- Approved pitch posts are pinned by the Discord bot when bot permissions allow it, and the operator panel reads
/api/pitchesfor the current pitch deck. - Expired pitch polls delete their original Discord webhook message after the vote is resolved.
- Now Playing Discord webhook messages are deleted when the episode finishes.
- OpenAI text generation and OpenAI TTS are configured separately.
UseEmbeddingsenables semantic memory recall. When disabled, memory context falls back to the most recent saved memories.- Some integrations only matter in specific scenes, such as
soccerinpolbots.
SlowModel and FastModel remain the default compatibility models. ModelProfiles can override specific LLM call classes without changing older call sites:
SlowandFast: direct replacements for the legacy binary model switch.Utility,PostProcess, andSentiment: cheap/default profiles for low-reasoning work.DialogueandSceneReasoning: higher-capability profiles for chain stages that benefit from stronger reasoning.
ModelPrices is keyed by model id. Exact model ids are matched first; versioned response ids such as gpt-5.4-mini-03-26 fall back to the longest configured prefix such as gpt-5.4-mini. The values in the example above are illustrative; update them when model pricing changes. Costs are calculated from response usage metadata when available:
InputPerMillion: uncached input token price.CachedInputPerMillion: cached input token price.OutputPerMillion: output token price.
Embeddings and TTS use the same ModelPrices table so the dashboard can show them in the budget widget without a separate cost pipeline. For embeddings, InputPerMillion is applied to estimated input tokens. For TTS, InputPerMillion is applied to input characters, so configure model ids such as gpt-4o-mini-tts and google-standard-tts with per-1M-character prices if you want speech cost included.
Current standard API prices for the main text generation models, in USD per 1M tokens:
| Model | InputPerMillion | CachedInputPerMillion | OutputPerMillion | Notes |
|---|---|---|---|---|
gpt-5.5 |
5.00 | 0.50 | 30.00 | Short-context standard price. Long-context pricing is higher. |
gpt-5.5-pro |
30.00 | 0.00 | 180.00 | No cached input price is listed. Long-context pricing is higher. |
gpt-5.4 |
2.50 | 0.25 | 15.00 | Short-context standard price. Long-context pricing is higher. |
gpt-5.4-mini |
0.75 | 0.075 | 4.50 | Cost-efficient general model. |
gpt-5.4-nano |
0.20 | 0.02 | 1.25 | Lowest-cost current GPT-5.4 class model. |
gpt-5.4-pro |
30.00 | 0.00 | 180.00 | No cached input price is listed. Long-context pricing is higher. |
chat-latest |
5.00 | 0.50 | 30.00 | ChatGPT model alias. |
gpt-5.3-codex |
1.75 | 0.175 | 14.00 | Codex-specialized model. |
The runtime currently stores one price row per model id. If a model has separate short-context and long-context prices, use the row that matches the expected workload or split usage into distinct model ids/config aliases before relying on budget totals for exact accounting.
When PersistUsage is enabled, usage records are appended as JSONL under UsageLogPath, one file per local date. Each call is tagged with usage type, profile, resolved model, prompt/template part, caller type/member, channel key, episode slug, token counts or billable units, cached/reasoning token details, latency, success/error status, and calculated cost.
Budgets controls warning thresholds:
DailyUsd: daily cost limit across all LLM calls. Set to0to disable daily budget warnings.PerEpisodeUsd: per-episode cost limit. Set to0to disable per-episode budget warnings.WarnAtPercent: percentage of a configured limit that emits a warning before the hard limit is exceeded.EnableUiWarnings: publishes budget warnings to the in-app UI event overlay.EnableDiscordWarnings: posts budget warnings to Discord when Discord webhooks are configured.DiscordChannel: optional webhook key for budget warnings. If blank, the current stream channel is used.
Budget warnings are also recorded in the operator event stream as llm_budget_warning.
LLM usage APIs:
GET /api/llm/calls?limit=100GET /api/llm/summary?groupBy=templateGET /api/llm/summary?groupBy=ipGET /api/llm/summary?groupBy=generatorGET /api/llm/summary?groupBy=modelGET /api/llm/summary?groupBy=profileGET /api/llm/budget?limit=1000GET /api/llm/usage?range=dayGET /api/llm/usage?range=weekGET /api/llm/usage?range=monthGET /api/llm/usage?range=allGET /api/llm/usage/calls?range=week&limit=24GET /api/llm/history/calls?date=YYYY-MM-DDGET /api/llm/history/budget?date=YYYY-MM-DD
soccer is scene-specific and currently powers three separate match-time layers in polbots:
Lines: live event narration text and residueInterruptSeeds: short pre-generated reaction seeds for injected micro-chats- announcer settings: optional TTS playback for narrated event lines
Example:
{
"Type": "soccer",
"MatchTimeLimit": 10,
"TimeBetweenGames": 0,
"MaxVolume": 1.0,
"EnableAnnouncer": true,
"AnnouncerVolume": 0.9,
"MaxAnnouncerQueue": 4,
"SkipAnnouncerDuringInterrupts": false,
"AnnouncerVoice": "alloy",
"ClearSceneOnGameEnd": true,
"RequireTextPatternMatch": false,
"GameOnStart": false,
"GameOnBatchEnd": false,
"GameOnMatchEnd": false,
"Lines": {
"FirstWhistleEvent": [
"The whistle blows.",
"And we're off.",
"Kickoff."
],
"FinalWhistleEvent": [
"# That's the final whistle! {1}",
"# Game over! {1}",
"# And that's full-time! {1}"
],
"RefereeShortWhistleEvent": [
"Quick whistle!",
"What's the call?",
"The ref steps in!"
],
"BallHitTheWoodWorkEvent": [
"SO CLOSE!",
"Denied by the post!",
"Off the bar!"
],
"PlayerSlideTackleEvent": [
"{0} lunges in!",
"{0} goes for the slide tackle!",
"{0} clatters into them!"
]
},
"InterruptSeeds": {
"Pregame": [
"Kickoff is approaching. Deliver a very short pregame beat about nerves, legitimacy, betting chatter, or alliance rivalry before play starts.",
"The match is about to begin. Deliver a very short pregame beat about diplomatic pageantry, procedural anxiety, or quiet panic before kickoff."
],
"GoalScoredEvent": [
"A goal has just been scored. React briefly and sharply without naming a specific score or minute.",
"A goal has changed the atmosphere instantly. Deliver a short reaction about humiliation, momentum, or political overreaction without citing exact numbers."
],
"RefereeShortWhistleEvent": [
"The referee has interrupted play. Deliver a brief reaction about procedure, officiating, corruption, or legitimacy.",
"A quick whistle just cut through the match. React briefly to the call, the process, or immediate suspicion of bias."
],
"KeeperSavesTheBallEvent": [
"A dramatic save just happened. React briefly without naming a specific score or minute.",
"The keeper just denied what looked inevitable. Deliver a short reaction about survival, theft, or divine intervention without exact numbers."
],
"BallHitTheWoodWorkEvent": [
"A near miss just rattled the stadium. React briefly without naming a specific score or minute.",
"The ball hit the woodwork and everything nearly changed. Deliver a short reaction about fate, robbery, or nerves without exact numbers."
],
"PlayerSlideTackleEvent": [
"A hard tackle just changed the emotional temperature of the match. Deliver a brief reaction.",
"A heavy slide tackle just landed. React briefly about aggression, legitimacy, revenge, or selective outrage."
]
}
}Notes:
Linesare short event phrases used for live narration, residue, and the optional announcer voice layer.InterruptSeedsare not dialogue output; they are brief generation seeds used to prebuild character reaction packets.- Keep
InterruptSeedsgeneric enough that pre-generated packets do not go stale immediately when the score changes. - Full-scene soccer framing now lives in Vault under
Vault/polbots/Prompts/Soccer Mode/Idea Seeds.
Generated chats are serialized to the user's Documents folder under a per-show directory:
Documents/<ShowName>/<chat-slug>.json
Those replay files can later be reloaded by the folder replay source and mixed back into the queue.
Prompt inputs and outputs are also written under Vault/<ShowName>/Inputs/... and Vault/<ShowName>/Outputs/... as generation runs.
ChatManagerContextdefines a show's cast, sentiments, spawn points, audio, config manager, and scene identity.- Scene-specific scripts should generally subscribe to core events instead of replacing the playback pipeline.
- If a show only needs different actors, prefabs, prompts, and locations, it can usually be built without adding new scene-side C#.
Older docs, file names, and comments may still refer to polbots. In the current project, that is just one show/context inside the larger HBOx runtime.