Standalone TypeScript library and CLI for playing the Trifle Snake Rodeo game.
Game model (odds pricing)
A bribe steers the snake (direction) and backs a team (team). The server is
price-authoritative: each bribe mints exactly 1 egg on the chosen team and
costs pricePerEgg(team), driven by that team's apple-progress win probability
— the leader is expensive, a trailing team is cheap (see src/lib/pricing.ts).
The first flatWindowSeconds (default 2, ≈ first half of a move) is a flat
opening window: every vote is priced at plain odds (ladder 1×) and neither resets
the clock nor steps the ladder — though a flip still changes heading. Only
post-window flips escalate the price ladder and reset the move clock. A vote for
the direction already winning this move by the same winning team is a buy:
it mints 1 egg at the frozen currentWinningPrice (the exact price the establishing
flip paid, held until the direction changes) without stepping the ladder or resetting
the clock. A vote for that direction by a different team is still a flip at that
team's own price (see src/lib/vote-pricing.ts). A flip
is priced at max(selected-team, fruit-team-on-target) — the anti-exploit
override (engine PR #430): if the tile the flip lands on holds another team's apple,
you pay at least that team's price, so you can't mint a cheap winning-team egg by
routing the snake onto a pricier team's fruit. On a win, the pot (seed + every
bribe) is split among the winning team's egg holders by egg count — no
refunds, losing eggs pay 0. Clients may send an optional maxPricePerEgg slippage
ceiling; there is no client-chosen bid amount.
A possible "traitorous hedge" strategy (steer cheap, hold elsewhere) is spec'd in
docs/hedge-module-spec.md. The engine has since shipped fruit-team flip pricing (PR #430, now mirrored here), which closes that vector live — so the active hedge module stays deferred.
Features
- Game state parsing — hex grid utilities, BFS pathfinding, flood-fill dead-end detection
- Odds-based pricing — client mirror of the server's per-team bribe pricing + egg payout split
- Strategy engine — pluggable strategies (expected-value, aggressive, conservative, random, rugger) with EV stake-defense + anti-farm grit
- Opponent model — read player balances/holdings, flag whales, pool squad/whale eggs
- Coordination — run several of your own bots as a squad that converges on one team with no messaging
- API client — framework-agnostic client for the trifle-bot server
- Wallet auth — SIWE (Sign In With Ethereum) authentication using viem
- Standalone runner — CLI agent that connects to a live server and plays autonomously
- Local simulator — offline game simulator, incl. a
shoverwhale adversary for stress-testing - Tournament CLI — high-speed seeded tournament runner for comparing strategies
Installation
npm install github:trifle-labs/snake-rodeo-agents
Quick Start (CLI)
# Play on the live server
npx snake-rodeo-agents --server live --strategy expected-value
# Play on staging
npx snake-rodeo-agents --server staging --name my-agent
# Tune strategy options (repeatable --strategy-opt key=value)
npx snake-rodeo-agents --strategy ev --strategy-opt grit=0.3 --strategy-opt coordination=focus-fire --strategy-opt squad=0xBotA+0xBotB
The CLI automatically creates a wallet, authenticates, and starts playing. Credentials are persisted in dist/bin/.state/ for reuse across sessions.
--strategy-opt key=value (repeatable) passes any option straight to the strategy
(grit, maxDefenseBribes, coordination, squad, whaleFactor, contestDiscount,
contrarian, …); values coerce to number/boolean where they look like one, and
squad is a +-delimited list of your bots' addresses/ids.
Library Usage
import {
SnakeClient,
createAndAuthenticate,
parseGameState,
getStrategy,
} from 'snake-rodeo-agents';
// Authenticate with a generated wallet
const { token, privateKey, address } = await createAndAuthenticate('https://bot.trifle.life');
// Save privateKey to reuse this wallet later (see Wallet Auth below)
// Create API client
const client = new SnakeClient('https://bot.trifle.life', token);
// Get game state and compute a vote
const rawState = await client.getGameState();
const parsed = parseGameState(rawState);
const strategy = getStrategy('expected-value');
const vote = strategy.computeVote(parsed, balance, {
currentTeam: null,
roundSpend: 0,
roundVoteCount: 0,
lastRound: -1,
gamesPlayed: 0,
votesPlaced: 0,
wins: 0,
// optional, for opponent-aware/coordination strategies:
// selfId: myUserId, myEggsByTeam: { A: 3 },
});
// Submit it. The server is price-authoritative (it charges its own
// pricePerEgg); pass a slippage CEILING + the nonce/round, not a bid amount.
if (vote && !('skip' in vote)) {
await client.submitVote(vote.direction, vote.team.id, {
maxPricePerEgg: vote.amount * 1.02, // shown price + tolerance
nonce: parsed.nonce,
round: parsed.round,
});
}
Wallet Auth
Authentication uses SIWE (Sign In With Ethereum). The library generates throwaway wallets — no real ETH needed.
Create a new wallet
import { createAndAuthenticate } from 'snake-rodeo-agents';
const { token, privateKey, address } = await createAndAuthenticate('https://bot.trifle.life');
// token: JWT for API calls
// privateKey: 0x-prefixed hex string — save this to reuse the wallet
// address: the wallet's Ethereum address
Reuse a saved wallet
import { reauthenticate } from 'snake-rodeo-agents';
const savedKey = '0xabc123...'; // previously saved privateKey
const { token, address } = await reauthenticate('https://bot.trifle.life', savedKey);
Use an existing viem account
import { authenticateWallet } from 'snake-rodeo-agents';
import { privateKeyToAccount } from 'viem/accounts';
const account = privateKeyToAccount('0xabc123...');
const token = await authenticateWallet('https://bot.trifle.life', account, {
chainId: 1, // default: 1 (mainnet)
domain: 'trifle.life',
uri: 'https://trifle.life',
});
Check if a token is still valid
import { checkToken } from 'snake-rodeo-agents';
const user = await checkToken('https://bot.trifle.life', token);
if (user) {
console.log(`Authenticated as ${user.username} (id: ${user.id})`);
} else {
console.log('Token expired, re-authenticate');
}
Telegram Logging
Optionally send game events (votes, wins, team switches, errors) to a Telegram group.
CLI
npx snake-rodeo-agents --server live \
--telegram-token "$TELEGRAM_BOT_TOKEN" \
--telegram-chat-id "$TELEGRAM_CHAT_ID"
Both --telegram-token and --telegram-chat-id must be provided; if either is missing, Telegram logging is silently skipped.
Library
import { TelegramLogger, formatVote, formatGameEnd } from 'snake-rodeo-agents';
const tg = new TelegramLogger({
botToken: process.env.TELEGRAM_BOT_TOKEN,
chatId: process.env.TELEGRAM_CHAT_ID,
});
// Send an arbitrary HTML message
await tg.send('<b>Hello</b> from the snake agent!');
// Use built-in formatters
await tg.send(formatGameEnd(winnerTeam, true));
Coordination (multiple bots)
Run several of your own bots as a squad that converges on one team without any
messaging — they share a deterministic team-selection policy computed from public
state (playerHoldings, playerBalances, odds), so every member reaches the same
choice independently. Tactics (steering, pricing, stake-defense) stay per-bot.
const opts = { coordination: 'focus-fire', squad: ['0xBotA', '0xBotB'] };
const botA = getStrategy('ev', opts);
const botB = getStrategy('ev', opts);
Or run each bot from the CLI (give every squad member the same squad list):
npx snake-rodeo-agents --name botA --strategy-opt coordination=focus-fire --strategy-opt squad=0xBotA+0xBotB
- Disabled (default): no
coordination⇒ each bot plays solo EV. focus-fire: the squad concentrates on the highest squad-pooled-EV team (same BFS + swing machinery as solo, resolved deterministically). Options:whaleFactor(balance multiple that flags an outsider as a whale, default 3) andcontestDiscount(0–1, default 1 = off; lower it to steer the squad away from teams a whale is contesting). The library reads opponent data viabuildOpponentModel(parsed, selfId, squad)— comms/persistence stay in your deployment, not here.
Whether coordination (and how much grit) helps is sensitive to the config and
the opponent model, so don't take a default on faith — measure it for your setup
with the simulator. In the current tournaments (vs the shover), coordination
helps on the smaller 2-team board and is mixed on the larger ones; grit is close
to neutral and can slightly hurt in long games (stubborn defense pays the laddered
price repeatedly).
⚠️ Caveat: the
shoveronly models denial farming (flip the snake away to grief). Against it the bots actually profit (the whale bleeds into the pot they harvest), so it does not reproduce a whale that farms by winning (backs a team to victory while baiting bots into overpaying). Treat these numbers as a lower bound on the threat, and tune defaults only once that adversary is modeled.
Tournament Simulator
Run offline tournaments to compare strategies at high speed with reproducible results.
CLI
# Compare expected-value vs aggressive (100 games per config, all configs)
npm run simulate -- ev,aggressive
# Specific config, seeded for reproducibility
npm run simulate -- ev,aggressive --games 50 --config small --seed 42
# Multiple strategies with options
npm run simulate -- ev,ev:contrarian,random --games 200
# Machine-readable JSON output
npm run simulate -- ev,aggressive --json
Options
| Flag | Description |
|---|---|
-g, --games N |
Games per config (default: 100) |
-c, --config NAME |
small|medium|large|all (default: all) |
-s, --seed N |
RNG seed for reproducibility |
-v, --verbose |
Print per-round details |
--json |
Machine-readable JSON output |
-h, --help |
Show help and available strategies |
Agent specs use the format strategy[:option[:option]] — e.g. ev, ev:contrarian, aggressive.
Simulator limitation (PR #435 deferred): the simulator does not yet model buys or the flat opening window —
shouldBuyis never called in simulation and every vote is priced as a post-window flip. Tournament results therefore do not reflect buy strategies; treat them as a lower bound until that follow-up lands.
Configs mirror production (trifle-bot
server/snake/config.tsRODEO_CYCLES): six hex/cart pairs, all 2–3 teams —small/mediumare 2-team boards,largeis the 3-team board (there is no 4-team rodeo). Keep them in sync when the server cycle changes. Notelarge(3 teams,fruitsToWin: 12, lots of fruit) is draw-prone with passive bots in the sim — the snake wanders and traps itself when nobody bribes; real games with active bribing resolve more often.
Library
import { SimAgent, runTournament, RODEO_CYCLES, getStrategy, createRNG } from 'snake-rodeo-agents';
const agents = [
new SimAgent('a', 'ev-agent', getStrategy('ev')),
new SimAgent('b', 'agg-agent', getStrategy('aggressive')),
];
const results = runTournament(agents, RODEO_CYCLES, 100, { seed: 42 });
console.log(results.agentStats);
// Re-run with same seed for identical results
Strategies
Defense aggression is a spectrum around the neutral expected-value brain,
controlled by two options — grit (how far past break-even it will bluff-defend
a stake, scaled by stake value, randomized so the fold point isn't farmable) and
maxDefenseBribes (how far up the price ladder it will fight in one move).
Strategies also expose a shouldBuy hook (post-PR #435) for minting additional
winning-team eggs at the frozen mid-move currentWinningPrice; the live runner
(bin/play.ts) calls it mid-move whenever the bot still leads a direction:
| Strategy | Description |
|---|---|
expected-value |
Neutral default. Win-seek when cheap (P(win) × pot × egg-share − pricePerEgg); defends an existing stake via the swing model (keeps bribing at a marginal loss when ceding would tank P(win) on eggs it already holds). Also makes marginal +EV buys at the frozen currentWinningPrice when leading — cheap accumulation of winning-team eggs that doesn't contest steering. grit defaults off (strict +EV) — in every simulated matchup it only costs ROI; turn it up only against an adaptive human who learns your fold point. BFS pathfinding + dead-end avoidance. |
aggressive |
High grit + high maxDefenseBribes: defends its stake hard and counter-farms shovers up the ladder. Opt-in archetype; costs ROI in plain play (risks being whale-bled). |
conservative |
Zero grit, maxDefenseBribes: 0: bribes only on strict +EV, folds to any shove (cheaply — never bled), skips when behind. |
random |
Random valid moves. |
shover |
Testing adversary, not a money-maker. Models a denial-farming whale: flips the snake away from the leader to deny it and bleed defenders. Pair with a deep balance to stress-test grit/defense, e.g. simulate ev,ev,shover:balanceMultiplier=10:maxRoundBudgetPct=0.7. |
rugger |
PROVISIONAL / experimental, whale-gated. The win-farming counterpart to shover: lies low on honest EV until a fat pot + a cheap, low-contention, reachable team lets it flip the underdog to victory and capture the whole pot (the rugged majority's losing eggs pay 0). Only engages with a clear balance edge; otherwise plays EV. Untuned — the simulator can't model buys/flat-window yet (Phase 2), so the trigger thresholds (whaleFactor, minPotMultiple, maxRivalEggsOnTarget, minVictimEggs) are guesses until validated. A valid "market" strategy by design — included to project it, not to guard against it. |
Architecture
snake-rodeo-agents/
├── src/ # TypeScript source
│ ├── index.ts # Public API exports
│ ├── lib/
│ │ ├── game-state.ts # Hex grid, BFS, flood-fill, state parsing
│ │ ├── pricing.ts # Odds-based bribe pricing (mirrors the server)
│ │ ├── vote-pricing.ts # Vote classification (flip vs buy) + flat-window pricing (PR #435)
│ │ ├── opponent.ts # Opponent model (balances/holdings, whales, squad pooling)
│ │ ├── client.ts # API client (SnakeClient)
│ │ ├── auth.ts # Wallet SIWE authentication
│ │ ├── simulator.ts # Local game simulator for testing
│ │ ├── telegram.ts # Optional Telegram logging
│ │ └── strategies/ # Pluggable strategy modules
│ │ ├── base.ts # BaseStrategy, EV/grit helpers, VoteResult types
│ │ ├── expected-value.ts # EV + stake-defense + grit + focus-fire coordination
│ │ ├── aggressive.ts # EV subclass: high grit / high defense
│ │ ├── conservative.ts # EV subclass: zero grit / never wars
│ │ ├── random.ts
│ │ ├── shover.ts # Adversarial whale: denial farming (testing only)
│ │ └── rugger.ts # EV subclass: provisional win-farming whale (rug)
│ └── bin/
│ ├── play.ts # Standalone CLI runner
│ └── simulate.ts # Tournament simulator CLI
├── dist/ # Compiled JS + declarations
├── package.json
└── tsconfig.json
License
MIT