You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Pac-Man where the characters are driven by System One decision models: TypeSafe's jev
(typesafe/jev-1.13.0, the default) and the others Opper serves, called through Opper or (jev only)
straight from TypeSafe. The main thing is to watch an AI play: press Play, pick a model with one click
(jev by default) and watch it play Pac-Man against the classic arcade ghosts. Below that are more ways to play:
Beat the AI (free, no sign-in: you against the classic ghosts, the same game the models played for the
leaderboard; game over tells you which AIs you beat, with a Share button and your personal best), Play against AI
ghosts (you steer, a model plays the ghosts) and AI vs AI (models on both sides). J hands Pac-Man to the AI
or takes him back during a game.
npm run leaderboard measures which model plays best, and you can add your own. While a model plays, its odds are drawn on the
board at each junction.
The side panel shows each decision with its probabilities, confidence, latency, the model that made
it and the running cost. Red entries were not the model's own choice: greedy fallbacks used when it
could not answer (timeout, error or invalid answer; see
How decisions work).
Public app: https://jevman.chadda.se. The former jevman.apps.chadda.se
address redirects here, preserving paths and query strings.
Production delivery
GitHub Actions tests each change and checks the
revision baked into the production image. With the repository variable
DOKPLOY_PUBLISH set to true, successful pushes to main publish a
smoke-tested Linux amd64 image to the public ghcr.io/joch/jevman-deploy
package. The workflow verifies the registry digest before promoting it to
:main. The host deploys by digest. Opper credentials and the session
signing key are supplied only at runtime.
Coolify delivery runs while COOLIFY_APP_UUID is set. Remove that variable
after verifying Dokploy delivery and the public route. Restore it if rolling
back to Coolify.
Keep the existing Opper credentials, SESSION_SECRET, and canonical
https://jevman.chadda.se/auth/callback in the host's runtime configuration to
preserve active sessions. The app needs no persistent server volume.
Visit analytics
Cloudflare automatically injects Web Analytics on the proxied public hostname;
the app does not embed a beacon. The shared chadda.se registration includes
all regions. In Web Analytics, filter Host to jevman.chadda.se for this
app's visits and pageviews. Do not add a second manual beacon.
Run
Requires Node ≥ 22.18, which runs the TypeScript server directly. Developed on Node 26; the Docker image uses Node 24.
npm install
cp .env.example .env # then set up option A, B or C below
npm run dev # http://localhost:5173
Option A — your own Opper key in .env
Set OPPER_API_KEY to a project-scoped Opper API key. Every jev call is billed to that key.
This is meant for local development: on an https deployment the key is ignored (unless you set
JEV_ALLOW_DEV_KEY=1), because it would pay for every visitor.
Option B — Login with Opper (players pay for their own play)
Register an OAuth app with Opper and add the redirect URI http://localhost:5173/auth/callback
(or your deployment's https://…/auth/callback).
Set OPPER_CLIENT_ID, OPPER_CLIENT_SECRET, OPPER_REDIRECT_URI and SESSION_SECRET
(openssl rand -hex 32) in .env.
Visitors who aren't signed in watch a recorded demo of a jev game. "Sign in with Opper" sends
them through Opper's sign-in; afterwards jev plays live and every call is billed to their own
Opper wallet. The player's key is kept in an encrypted, httpOnly cookie (the page's JavaScript
never sees it). "Sign out" clears the cookie; to revoke the app's key entirely, remove jevman under
connected apps in your Opper wallet.
Option C — your own TypeSafe key (jev straight from TypeSafe)
jev is made by TypeSafe. To call TypeSafe's own System One API instead of going
through Opper, get an API key from TypeSafe (see docs.typesafe.ai)
and set TYPESAFE_API_KEY in .env (and optionally TYPESAFE_BASE_URL, default
https://api.typesafe.ai). Calls then go to POST {TYPESAFE_BASE_URL}/v1/systemone with model
jev-1.13.0; the request and answers are the same as through Opper. TypeSafe's API returns no
cost, so the panel shows an estimate (≈) from token usage at TypeSafe's published price
($0.042 per million input tokens; output is free). Like option A this is a local key, ignored on
https deployments unless JEV_ALLOW_DEV_KEY=1. npm run smoke and npm run bench use it too.
This is also the easiest way to play jevman without an Opper account: clone the repo, add your
TypeSafe key, npm run dev. The hosted page links here from its header, under "Sign in with Opper".
Which key is used
A signed-in Login-with-Opper player always plays on their own key, through Opper. Otherwise the
server uses TYPESAFE_API_KEY if set, else OPPER_API_KEY. Variables exported in your shell take
precedence over .env. The account area in the page header says which one is in use.
Another model
jev is the default, but any model that speaks the System One API can play: set JEV_MODEL to its
name, for example opper/kev-4b or opper/clef through Opper. It applies to the game, npm run smoke
and npm run bench, and recordings note the model that played. Through TypeSafe directly (option C)
the cost estimate still uses jev's price. It is the server's default: a model the game or the bench
names explicitly (--pacman-model, npm run leaderboard) must be one of the
decision models and wins over it.
Scripts
npm test — unit tests (includes a check that the committed demo still replays exactly).
npm run build / npm start — production build, then the production server on PORT (default 3000).
npm run smoke — one real batched call to jev with the .env key (TypeSafe or Opper).
npm run bench — headless real-time games reporting survival time, score, pellets, deaths,
calls, fallbacks, latency and cost. Paid whenever a model drives a character.
Flags: --games 4 (played in parallel), --pacman jev|greedy and --ghosts greedy|jev (jev
means "a decision model"), --pacman-model and --ghost-model (any id below; default jev),
--max 120 (seconds per game), --record path.json (with --games 1: save the game for replay). Each model gets a warm-up call
first, because Opper-hosted models can take many seconds to answer after being idle. The demo was recorded with
npm run bench -- --games 1 --pacman jev --ghosts greedy --max 120 --record public/demo/jev-demo.json;
re-record it if a change to the game rules makes the replay test fail.
npm run leaderboard — every decision model plays Pac-Man against the scripted ghosts, so the
model itself is measured. Writes public/leaderboard.json (ranked by
mean score), which the site shows at /leaderboard, and prints a table. Same flags as bench, plus --models id,id (default: all),
--parallel 4 (games at a time per model); defaults to 8 games per model and a 300 s cap.
npm run bench -- --endpoint http://… — plays Pac-Man with any model behind your own HTTP endpoint; with
--submit submissions/<id> --name … --by … it records the leaderboard's games as a submission. See
CONTRIBUTING.md.
npm run submissions — replays every submitted game in submissions/ and writes
public/community.json, the leaderboard's self-reported list. The image build runs it too, so a submission that
doesn't check out fails CI.
npm run deaths -- game.json — replays a recorded game and prints, for each of Pac-Man's deaths,
his last few junction decisions with the routes as jev saw them.
Decision models
Opper serves several System One decision models with the same API, listed in
shared/models.ts: typesafe/jev-1.13.0 (TypeSafe, the default),
opper/clef and opper/clef-flash (Cloudflare), opper/kev-4b (a Qwen3.5-4B fine-tune by Jared
Palmer) berget/convaiinnovations/laya (ConvAI Innovations; its 512-token context is shorter
than one of our questions) and openai/gpt-6-luna-decisions (OpenAI's GPT-6 Luna Decisions, which Opper translates
to System One). The server forwards only these. Through Opper any of them can play; a
TypeSafe key (option C) reaches jev only.
Any other model can join the leaderboard as self-reported: its makers run the benchmark against their own
endpoint and send the recorded games in a pull request, which CI replays. See
CONTRIBUTING.md.
In the game, pick the models in the Play dialog: one for Pac-Man (the chips) and one for all the ghosts. Each card on the
decision panel has a dropdown to switch that character's model, also mid-game. Choices are remembered in your browser; the default is the server's
(jev, or JEV_MODEL), and the game only names a model when you pick another one. Opper-hosted
models scale down when idle, so picking one sends a tiny warm-up call (POST /api/warm, one cheap
call): Play waits for it ("Waking up Clef…"), and a mid-game switch takes over once the new model
answers.
Controls
Arrows/WASD steer Pac-Man when you play him · J or the Pac-Man button hands him to the AI or back · P pause ·
R restart · M sound on/off · the speed slider slows the game down. Space/Enter presses Watch (or Play); Restart and R end the game and open the Play dialog, Play again replays the same setup. On a phone,
swipe on the board or use the on-screen pad.
Cost
Each jev call costs about $0.00005, through Opper or straight from TypeSafe. With jev playing
Pac-Man a game makes about 1–2 calls per second, so a typical game (two to three minutes until he
runs out of lives) costs about $0.01, or about $0.35 per hour of continuous play. When you
steer Pac-Man and jev plays the four ghosts it makes about 4 calls per second, about $0.75 per
hour; with the AI on both sides, about the two together. Lowering the speed makes fewer calls. npm run bench reports the exact cost per game.
Benchmark your own model
Any model can join the leaderboard as self-reported. Put it behind an
HTTP endpoint that answers jevman's questions (copy scripts/example-endpoint.ts),
then:
npm run bench -- --endpoint http://localhost:8787 --games 2
npm run bench -- --endpoint http://localhost:8787 --submit submissions/my-model --name "My Model" --by your-github-handle
npm run submissions
The second command plays the leaderboard's 24 games and writes them to submissions/my-model/; the third checks them
the way CI will. Commit the folder and open a pull request: CI replays every game and checks its score. The request
and answer format, the rules and what "self-reported" means are in
CONTRIBUTING.md.
How decisions work
When a character commits to a corridor its next junction is known, so the game asks jev about it
straight away (one System One request per frame, one choice question per character, options =
legal directions described with computed facts: distances to pellets, power pellets, fruit and
ghosts, whether the nearest ghost is coming closer, and whether a ghost can reach the end of the
corridor before Pac-Man). Up to three requests are in flight at once. If the character reaches the
junction before the answer, it waits there (its panel card says "thinking…"). After 2 s, or on an
error, it uses a greedy rule.
Pac-Man can also get a second question mid-corridor: when a dangerous ghost is in the corridor
ahead, or can reach the junction at its end before he does, jev is asked whether to keep going or
turn back right now (pacman_escape). Pac-Man keeps moving while it is open, and each situation is
asked once.