Skip to content

Asking Jev from a game

This page is the design for how a game’s own code asks Jev, TypeSafe’s System One model, for a judgement. Nothing on this page is built yet; the build issues list what builds it. The creator’s own TYPESAFE_API_KEY pays for every call. The platform holds no key and pays for no call, and the key never reaches a player’s browser.

A game sends Jev a state and a set of typed questions, and gets typed answers back. The three question types are Choice, Score and Noul (primitives). The typical in-game use is an NPC’s next action: one Choice between attack, flee, talk and idle, asked about a state that describes the NPC and the hero.

Jev judges; it does not generate text, and it is weak at counting, numbers and dates (Jev 1.13’s known weak spots). The game therefore computes distances, health ratios and cooldowns in code, and puts the results in the state as plain fields.

The call always runs in the game’s server half, which is the only code that holds the key. The browser runs the client half, and never sends a request to TypeSafe.

A game with rooms asks from its room, beside the simulation. A server-declared system queues the ask on a trait. The room sends it with the key from the room’s environment, and writes the answer back onto the trait on a later step. The NPC’s resulting action reaches each client through the ordinary replication of world state, so a client never sees the question, the answer or the key.

A single-player game: a per-game server function

Section titled “A single-player game: a per-game server function”

A single-player game has no room, and the browser cannot hold the key. The game’s server half therefore also deploys as a stateless function, with the same code and the same secret as a room would have. The function works as follows:

  1. The browser sends the function the name of an ask the game declared, plus the state.
  2. The function checks the player’s game token, and refuses a player who asks faster than the per-player rate limit.
  3. The function sends the declared questions and the state to Jev with the creator’s key, and returns the answers.
  4. The browser writes the answers onto the NPC’s trait on the next step.

The questions live in the function, not in the request. A player who calls the function directly can therefore change only the state, and cannot use the endpoint to ask Jev anything else on the creator’s key. The function runs as an edge worker beside the room service or as a Convex action, whichever hosts the rooms.

Two other places were considered and rejected:

  • A room of one. Every session of a single-player game would open a room. That reuses the multiplayer path, but the platform pays for a room each session, and the north star says a single-player game needs no room.
  • The creator’s own server. The key would never touch the platform, but the trust boundary does not let a game reach a developer’s own server at launch, and a creator with no code has no server.

The key is a secret of the game’s server half. It is never an environment variable the browser build can read.

  • Locally: the dev server’s server half reads TYPESAFE_API_KEY from the shell, or from a .env.local file that git ignores. Vite exposes only variables prefixed with VITE_ to browser code, so the name TYPESAFE_API_KEY stays out of the client bundle.
  • At publish: the CLI reads TYPESAFE_API_KEY from the creator’s environment and stores it once as a write-only secret on the game’s server half, as wrangler secret put stores a Worker’s secret. The platform never prints it and never reads it back. Only the room and the function read it when they run.

Roblox’s secrets store and Unity’s Secret Manager work the same way; see How other engines do it.

An answer that arrives several steps later

Section titled “An answer that arrives several steps later”

An answer takes 100 to 400 ms, which is 6 to 24 steps of 1/60 s. The step never waits for it:

  1. On a step where the NPC has no ask in flight and its cooldown has passed, a system builds the state from traits and queues an ask. The ask’s trait records the step it asked on.
  2. The server half sends the queued asks after the step, outside it.
  3. When the answer arrives, the server half writes it onto the trait before the next step starts.
  4. A system reads the answer on the next step and changes the NPC’s action.

While an ask is in flight, the NPC keeps its current action. An answer that arrives after its deadline is dropped, and a failed call counts as late. In either case, a rule written in code chooses the action, so the NPC still acts when TypeSafe is slow or down.

Asks from the same step go in one call. Jev reads the state once and answers every question in parallel (speculative fan-out), and the measurements show one call for 10 NPCs is as fast as one call for 1 NPC. Batching also keeps a room under the key’s rate limit of 1,200 requests a minute (models), which is shared by every room and function of every game on that key. A cap on calls in flight per room, and a cooldown per NPC, bound the rest.

A game with no key set, a headless test, or the harness gets answers from the code rule alone. A test can also hand the harness a fake that answers every ask, so it runs with no network.

The following sketch shows one ask, its trait and the system that reads it. The names are placeholders; each build issue proposes the final names.

src/npc/intent.ts
import type { World } from "koota";
import { AskStatus, AskTrait, defineAsk } from "@daniel-zarinski/engine";
import { NpcAction } from "./traits";
// Declared once. The questions ship with the server half, so a player
// cannot change them; only `state` runs per ask.
export const npcIntent = defineAsk({
name: "npc-intent",
questions: {
action: {
type: "choice",
instructions: "What should this NPC do next?",
criteria: {
attack: "Fight the hero: healthy enough, and the hero is a threat.",
flee: "Run away: badly hurt or outmatched.",
talk: "Speak to the hero: near, calm and not hostile.",
idle: "Keep standing guard: nothing calls for a reaction.",
},
},
},
// Pure, built from traits. Distances are computed here, not by Jev.
state: (npc, hero) => ({
health: `${npc.health.current} of ${npc.health.maximum}`,
distanceToHeroMetres: npc.distanceToHero,
heroArmed: hero.armed,
}),
cooldownSeconds: 2,
deadlineSeconds: 1,
// The code rule: used while no answer is fresh.
fallback: (npc) => (npc.health.current < 25 ? "flee" : "idle"),
});
// A server-declared system. It reads the answer; it never awaits. The
// engine writes `answers` from Jev, or from `fallback` past the deadline.
export function chooseNpcAction(world: World) {
world.query(NpcAction, AskTrait).updateEach(([action, ask]) => {
if (ask.name === npcIntent.name && ask.status === AskStatus.Answered) {
action.current = ask.answers.action.choice;
}
});
}

A component attaches the ask to an entity in the same way as any behaviour:

<Entity
model="guard"
chase={{ speed: 3 }}
health={{ maximum: 100 }}
ask={npcIntent}
/>

The ask depends on two things the engine does not have yet: a game registering its own systems, and a server half that runs the step.

One NPC decision, measured against a snapshot of games/mmorpg’s world on 2026-09-23. The mmorpg has no NPC, so the snapshot adds a guard built from the engine’s chase, health and interact traits, 8 m from the hero, and steps the world one second.

Call Runs p50 p95 Input tokens Cost per call
One NPC, one call after another 20 159 ms 363 ms 506 $0.000021
One NPC, 10 separate calls at once 10 444 ms 593 ms 506 $0.000021
10 NPCs in one call, one question per NPC 5 142 ms 221 ms 2,412 $0.00010

What the numbers show:

  • One call answers in about 150 ms, the figure TypeSafe claims (use cases), with a tail near 400 ms.
  • Separate calls sent together slow each other down. A first run measured the 10 separate calls at 1,000 ms p50, and the first live run of the CLI’s wiki lint measured 560 ms for 10. Batching the NPCs into one call avoids that.
  • Cost is small: Jev charges $0.042 per million input tokens, and output tokens are free. One NPC asking every 2 seconds for an hour is 1,800 calls, or about $0.04.
  • The model was jev-1.13.0. It answered talk in all 20 runs, with a confidence of 0.54.

The recipe has two steps:

  1. Write the snapshot. A scratch test in games/mmorpg/test/ builds the world headless and dumps it; run it with pnpm exec vitest run --root games/mmorpg test/jev-snapshot.test.ts.

    import { writeFileSync } from "node:fs";
    import { Vector3 } from "three";
    import { it } from "vitest";
    import {
    ChaseTrait,
    createHeadlessGame,
    dumpState,
    HealthTrait,
    InteractTrait,
    placePlayer,
    stepSeconds,
    Transform,
    } from "@daniel-zarinski/engine/core";
    it("writes a snapshot with one NPC near the hero", async () => {
    const game = await createHeadlessGame({
    scene: (world) => {
    world.spawn(
    Transform(new Vector3(8, 0, 6)),
    ChaseTrait({ speed: 3, reach: 1.2 }),
    HealthTrait({ current: 35, maximum: 100 }),
    InteractTrait({ prompt: "Talk to the guard", radius: 2 }),
    );
    },
    });
    const hero = placePlayer(game, new Vector3(0, 0, 0));
    hero.set(HealthTrait, { current: 80, maximum: 100 });
    stepSeconds(game, 1);
    writeFileSync(
    `${process.cwd()}/.work/snapshot.json`,
    JSON.stringify(dumpState(game.world), null, 2),
    );
    game.world.destroy();
    });
  2. Time the calls. node .work/measure.mjs, with TYPESAFE_API_KEY set, builds the state from the snapshot’s guard and hero and calls POST https://api.typesafe.ai/v1/systemone directly:

    const distance = Math.hypot(
    npc.transform[0] - hero.transform[0],
    npc.transform[2] - hero.transform[2],
    );
    const state = {
    npc: {
    name: "Bran",
    role: "guard of the meadow",
    health: "35 of 100",
    distanceToHeroMetres: Math.round(distance * 10) / 10,
    attackReachMetres: 1.2,
    heroCanTalk: true,
    },
    hero: { health: "80 of 100", pose: "idle", armed: true },
    };
    const questions = {
    action: {
    type: "choice",
    instructions:
    "What should Bran, a non-player character in a fantasy game, do next?",
    criteria: {
    attack: "Close in and fight the hero: the NPC is healthy enough and the hero is a threat or an enemy.",
    flee: "Run away: the NPC is badly hurt or clearly outmatched.",
    talk: "Speak to the hero: the hero is near, calm and not hostile.",
    idle: "Keep standing guard: nothing calls for a reaction.",
    },
    },
    };
    const started = performance.now();
    const response = await fetch("https://api.typesafe.ai/v1/systemone", {
    method: "POST",
    headers: {
    Authorization: `Bearer ${process.env.TYPESAFE_API_KEY}`,
    "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "jev-latest", state, questions }),
    });
    const { answers, usage } = await response.json();
    console.log(performance.now() - started, answers.action, usage);

    The script sends one warm-up call, then 20 calls in sequence and 10 at once. It then makes five batched calls whose state lists 10 guards 2 m apart, with one action_<name> question per guard.

Roblox and Unity keep the key on a server they host, in a store built for secrets. Unreal and Godot have no such store, and leave you to run your own backend. Every engine makes the call without blocking the frame.

  • Roblox: a server script calls out through HttpService, a yielding call (HttpService). The key lives in the game’s secrets store and is read with HttpService:GetSecret. “For security reasons”, the store is available only to live servers and collaborative testing, never to a client (secrets store). Roblox also offers its own hosted model to game code, TextGenerator, for NPC dialogue (TextGenerator).
  • Unity: Cloud Code modules run on Unity’s servers and may call any external API; its stated purpose is to “keep game rules and sensitive data off the client” (Cloud Code). A module reads the key from Secret Manager (secrets in modules). Each player may make 600 requests a minute, and a call that runs past 15 seconds is killed (limits). The developer pays for Cloud Code.
  • Unreal Engine: any code can call out through the HTTP module; ProcessRequest starts a request and a completion delegate receives the answer (IHttpRequest). The engine has no secret store. Hosted NPC dialogue exists only inside Fortnite’s editor (LLM conversations in UEFN); outside it, vendor plugins such as Inworld and Convai take the vendor’s key in the project, and the developer pays the vendor.
  • Godot: any script calls out through the HTTPRequest node, and a signal delivers the answer. The docs warn that a released game can be decompiled to recover “any embedded authorization information like tokens, usernames or passwords” (HTTPRequest).

This design takes the shape Roblox and Unity share: the call runs in server code the platform hosts, and the secret is write-only and belongs to the creator. It differs from Roblox’s TextGenerator because the creator’s own account pays for the calls, and the platform runs no model.

The trust boundary says a room process reaches the backend and nothing else, and holds no secret beyond its room credential. A game that asks Jev needs two exceptions, for its room and for its function:

  • It may reach api.typesafe.ai.
  • It may hold one creator secret, TYPESAFE_API_KEY.

The browser’s content security policy does not change: the page still reaches only the engine, the bundle, the backend and the room.

The design implies the following work, in order:

  1. A game registers its own systems, which #345 and #93 cover.
  2. The engine declares an ask, keeps it on a trait, falls back to the code rule, and lets the harness fake Jev.
  3. A room sends its declared asks with its secret, after the room hosting in #27.
  4. The per-game server function answers a single-player game’s declared asks.
  5. The CLI stores the key as a secret at publish, and the dev server reads .env.local.
  6. The trust boundary page admits TypeSafe and one creator secret.
  7. The mmorpg gets a guard that decides with Jev, as the working example.