featured.svg

1979 年由 Atari 推出的經典街機遊戲《Asteroids》(隕石狂飆),是許多人對大型電玩的最早記憶之一:一艘三角形的小太空船漂浮在無重力深空中,利用牛頓慣性推進與 360 度旋轉穿梭於隕石群之間。擊中巨石會分裂成中型碎塊,碎塊再裂成小型疾馳的礫石,而螢幕邊緣更具備環狀包覆(toroidal wraparound)——從左側飛出就會從右側現身。

小時候玩這款遊戲,總覺得最致命的往往不是手眼協調,而是局勢判斷:你究竟應該主動迎擊迎面而來的威脅、朝安全空曠處重新卡位,還是全力急轉閃避即將撞上的碎屑?

最近為了深入練習現代 TypeScript,順便摸索 TypeSafe AI 推出的 System One 結構化決策模型 Jev,我動手寫了一個練習專案:hello-jev。我用 TypeScript 與 Canvas 從頭打造了這款經典遊戲,並串接 Jev 實作了一具即時的「AI 飛行副駕駛(autopilot flight computer)」。不同於輸出自由文字的生成式 LLM,Jev 專門處理型別化的離散決策與機率分佈,非常適合擔任需要嚴謹邏輯的戰術大腦。

你可以隨時手動駕駛,按下 J 鍵交給 Jev 接手;而在副駕駛接管期間,右側的飛行儀表板會即時展示 Jev 當前的戰術決策、機率分佈(probabilities)、置信度(confidence)與伺服器 SDK 延遲。只要你一碰方向鍵或空白鍵開火,系統會在鍵盤事件中立即切換模式,並在下一個物理步無縫套用手動輸入。

先來看看 Jev 與在地控制器協同作戰的實機錄影。在這段約 84 秒的完整戰況中,自動駕駛成功清空了第一星區(Sector 01,得分達 2,600 並順利挺進 Sector 02),三艘飛船全數完好無損,右側飛行電腦更即時呈現了完整的戰術決策、機率分佈與呼叫紀錄:

但仔細想想,讓雲端 AI 模型來開太空船,這件事在工程上真的行得通嗎?

即時遊戲與 AI 模型的衝突

如果我們用最直覺的思維來設計 AI 飛行員,直覺的想法可能是:

「每一幀都截圖或把太空船座標餵給模型,問它現在該按『向左轉』、『推進』還是『開火』?」

這種做法只要一跑起來,就會立刻撞牆:

  1. 幀率預算的極限 (Frame Budget):遊戲實體物理模擬通常採用固定步長循環(fixed-step simulation)。在 hello-jev 中,物理模擬以 120 Hz 執行(每步約 8.3 毫秒,由 requestAnimationFrame 驅動累加器),這與瀏覽器的畫面渲染幀率解耦,若遇掉幀會在單一畫面幀中連續補償多個物理步。即使是最快的雲端推論 API,單次呼叫加上網路往返通常也需要數十至數百毫秒。用高延遲的網路 API 驅動毫秒級的連續物理迴圈,飛船早就撞毀幾十次了。
  2. 浮點幾何與向量運算的盲區:大語言模型擅長語意關聯與戰術權衡,但讓它在心智中做即時的二維三角函數計算(例如預判 640 px/s 子彈速度與兩團漂移物體的交會前置角),往往不穩定且消耗大量運算資源。
  3. 無重力環狀空間的複雜度:遊戲邊界具有循環環形(wraparound)特性。當飛船在 x = 10,隕石在 x = 990,幾何歐氏距離看似 980,但實際跨邊界距離只有 20。若把原始座標直接丟給模型,它很難迅速掌握環狀拓撲結構。

那麼,我在打造 hello-jev 時,是如何化解這個矛盾的?

答案就是現代自主機器人與自駕車領域行之有年的架構思維: 雙層控制架構 (Hierarchical Control Architecture)——將「宏觀戰術評估」與「微觀物理執行」徹底解耦。

雙層架構:思考者與行動者的分工

hello-jev 的設計中,我把飛行副駕駛精確切分為兩層不同頻率的系統:

  • 宏觀戰術層 (Deliberative Tactic Layer / Jev): 運行頻率低(在正常連續運作下,名義上約每 1.2 秒發送一次請求,且保證隨時最多只有一個在途請求)。它不負責「現在轉動 0.05 徑度」這種微操,而是回答宏觀問題:「面對目前的空域威脅,我該採取哪種戰術姿態(攔截、閃避或重新佔位)?如果攔截,優先鎖定哪顆隕石?」
  • 微觀物理執行層 (Reactive Controller / 120 Hz 在地控制器): 以確定性演算法在瀏覽器本機端以 120 Hz 固定步長執行。它接收 Jev 最新下達的戰術指令,並直接讀取當前飛船與隕石的即時幾何座標,計算出每個微步精準的轉向控制量(turn)、推進脈衝(thrust)與前置量射擊(lead aiming)。
  • 即時防衛反射機制 (Emergency Reflex Check): 在地控制器內建持續性的防衛反射檢測。在每個物理微步中,控制器會外推未來 0 至 0.8 秒內的近距威脅,只要有任何隕石進入安全包絡線(距離小於半徑加上 44 像素),無論當前是否在等待 Jev 回應,都會強制切換為逃逸姿態,有效降低等待模型回應期間的碰撞風險。

整個系統的資料流向如下圖所示:

architecture.svg

這套架構讓 Jev 專注發揮其巨觀場景判斷的高級能力,而把高頻率、需要精準數值解算的物理驅動,留給瀏覽器端輕量高效率的 TypeScript。

雷達快照:將連續空間幾何語意化

要讓 Jev 做出精明判斷,第一步是:如何把複雜的 2D 物理狀態餵給模型?

src/game.tssnapshot() 中,我並沒有把整張畫布的座標直接丟給模型,而是寫了一段簡潔的幾何前處理,將空間狀態萃取為最多 10 顆鄰近隕石的「雷達快照(FlightSnapshot)」:

snapshot(sequence: number): FlightSnapshot {
  const round = (n: number) => Math.round(n * 10) / 10;
  const asteroids = this.rocks.map(r => {
    // 1. 計算考慮環狀邊界(wraparound)後的真實相對位移向量
    const d = delta(this.ship, r), dist = length(d);
    // 2. 計算相對速度向量
    const v = { x: r.vx - this.ship.vx, y: r.vy - this.ship.vy };
    // 3. 假設相對速度恆定,透過內積計算最接近時間點 t(限制在 0 到 5 秒之間)
    const dot = d.x * v.x + d.y * v.y;
    const t = Math.max(0, Math.min(5, -dot / (v.x * v.x + v.y * v.y || 1)));

    return {
      id: r.id,
      distance: round(dist),
      // 相對於船頭的方位角(-180 到 180 度,0 度表示正前方)
      bearing: round(angleDifference(Math.atan2(d.y, d.x), this.ship.angle) * 180 / Math.PI),
      radius: r.radius,
      // 徑向接近速度(正值表示正在接近,負值表示遠離)
      closingSpeed: round(-dot / (dist || 1)),
      // 線性外推下,最接近時的兩者預估表面間距(負值代表若雙方維持等速直線運動將發生碰撞)
      closestApproach: round(Math.hypot(d.x + v.x * t, d.y + v.y * t) - r.radius - SHIP_RADIUS),
      secondsToClosest: round(t),
    };
  }).sort((a, b) => a.distance - b.distance).slice(0, 10);

  return {
    sequence,
    wave: this.wave,
    lives: this.lives,
    ship: {
      speed: round(Math.hypot(this.ship.vx, this.ship.vy)),
      heading: round(wrap(this.ship.angle * 180 / Math.PI, 360)),
      invulnerable: this.ship.shield > 0,
    },
    asteroids,
  };
}

這段前處理的設計考量包含:

  1. 消除環形維度的歧義:透過 delta(from, to) 計算最短環形位移(1000×700 視口),輸出乾淨的相對距離向量。
  2. 直接給出衍生指標 (Derived Metrics):模型不需要拿速度與位置去解二階方程。快照在假設相對速度恆定(constant relative velocity)的前提下,提供 5 秒時間視界內的線性外推預估:secondsToClosest 是預估的最接近時間點(而非必定相撞的倒數計時),closestApproach 則是基於碰撞球體半徑估算的最小表面淨距。
  3. 相對航向角 (Relative Bearing):將絕對座標轉換成「相對於飛船當前朝向」的角度( 為正前方,負值為左,正值為右),讓模型能以最符合飛行員本能的視角進行決策。
  4. 中心距離排序取樣:選取距離飛船中心最近的 10 顆隕石餵給模型,控制酬載大小並聚焦局部威脅。

向 Jev 發問:TypeSafe 結構化查詢與機率決策

有了語意清晰的雷達快照,我在後端伺服器 server/pilot.ts 中是如何向 Jev 索取決策的?

這裡使用的是 TypeSafe AI SDK 的 @typesafe-ai/sdk。不同於傳統大模型總是輸出一段難以約束格式的文字,TypeSafe 提供了一種專注於型別化結構決策的介面:client.systemOne()choice()

一次往返問完兩個獨立問題

server/pilot.tsquestionsFor() 中,我設計在單一 SDK 呼叫中同時提出兩個相互獨立的結構化問題:

export function questionsFor(snapshot: FlightSnapshot) {
  // 動態建立當前可選的目標隕石清單
  const targets: Record<string, string> = {
    none: 'No useful asteroid target; focus on survival or wait for the next wave.',
  };
  for (const r of snapshot.asteroids) {
    targets[r.id] = `Asteroid ${r.id}; details are in asteroids. Choose using danger, firing alignment, distance, and ease of interception.`;
  }

  return {
    // 問題 1:決定當前戰術姿態
    maneuver: choice(
      'Choose the best tactical maneuver for the next roughly 1.2 seconds in Asteroids. Survive and destroy rocks. State uses pixels, pixels/second, degrees, and seconds. Bearing 0 is straight ahead; negative is left. Negative closestApproach means a predicted collision if velocities stay constant. secondsToClosest=0 can mean a rock is moving away: inspect closingSpeed (positive means approaching). A local reflex avoids imminent collisions. Favor intercept when there is no approaching collision threat; reposition when crowded or moving too fast to aim; evade when a near collision is predicted. Shield gives temporary protection, not permanent safety.',
      {
        intercept: 'Aim at a selected asteroid, lead the shot, and fire. Approach distant targets at moderate speed.',
        evade: 'Prioritize thrust toward open space to escape a collision threat. Fire opportunistically.',
        reposition: 'Move to a clearer part of the field to regain room to aim. Fire opportunistically.',
      }
    ),
    // 問題 2:若選擇攔截,獨立評估最佳目標
    target: choice(
      'Independently choose the most useful asteroid to aim at if intercept is selected. Prefer a nearby, aligned target or a closing threat that can be destroyed. Use none if no useful target exists. This answer cannot see the maneuver answer.',
      targets
    ),
  };
}

為什麼是 System 1?

在認知科學中,丹尼爾·康納曼(Daniel Kahneman)將人類思維劃分為直覺快速的「系統一(System 1)」與審慎慢速的「系統二(System 2)」。

在即時街機遊戲裡,你不會想等待模型進行一長串思維鏈(Chain-of-Thought)推理,寫出「首先我觀察到隕石 A 正在接近,因此我推論…」這樣的冗長文字。每一毫秒的文字生成都在浪費寶貴的反應時間。

client.systemOne() 正是針對快速直覺分類與離散決策設計:

const response = await client.systemOne({
  state: { ...snapshot, asteroids: snapshot.asteroids.map(r => ({ ...r })) },
  questions: questionsFor(snapshot),
}, { signal });

原生機率分佈與置信度

更關鍵的是,TypeSafe 的 choice 傳回的不僅僅是最終選定的標籤(如 "intercept"),還包含了完整的機率分佈與代表分佈集中程度的置信度 (confidence):

const m = response.answers.maneuver;
// m.choice: 'intercept'
// m.confidence: 0.78  // 衡量選項機率分佈的集中度
// m.probabilities: { intercept: 0.82, evade: 0.11, reposition: 0.07 }

需要特別釐清的是:confidence 反映的是整個機率分佈的明確程度,它既不是獲勝選項的單一機率,也不代表動作的成功率或存活率。在 hello-jev 的前端儀表板中,我們將這組機率直接渲染為動態長條圖(probability bars)與置信百分比,供駕駛員即時監控模型的決策偏好。而在程式碼控制層,飛船直接採納勝出的 choice,並未設置信心度門檻來阻擋行動。

當前方空域開闊且有一顆正前方接近的隕石時,實測中 intercept 的機率常顯著提升;而當周遭被多顆碎石包圍時,evadereposition 的機率就會大幅上揚,真實展現出 AI 在戰術權衡時的量化評估。

微觀物理執行:在地控制器的解算細節

Jev 下達了宏觀指示,例如 maneuver = 'intercept'target = 'r04'。接下來的 1.2 秒內,瀏覽器端必須以 120 Hz 的物理頻率,把這個意圖轉換成飛船的具體動作。

這些邏輯全實作在 src/game.tssteer() 函式中。

前置量預判射擊(Lead Shooting)

如果戰術是 intercept,飛船絕不能朝著目標當前的位置開火——因為子彈飛行需要時間,直接對準現有座標必定射偏。

在遊戲物理中,砲彈發射時會繼承飛船當前速度(初始相對船頭速度為 BULLET_SPEED = 640 px/s,世界座標速度為飛船速度加上子彈初速)。在地控制器會計算相對速度向量,進行一階前置量預估:

const d = delta(ship, target);
// 一階飛行時間估算
const travel = length(d) / BULLET_SPEED;

// 預判目標在 travel 秒後的位移向量,計算理想瞄準角度
const desired = Math.atan2(
  d.y + (target.vy - ship.vy) * travel,
  d.x + (target.vx - ship.vx) * travel
);

// 只有當距離較遠 (>260px)、飛船速度不過快 (<170px/s) 且船頭大致對齊時才開推進
const thrust = length(d) > 260 && Math.hypot(ship.vx, ship.vy) < 170
  && Math.abs(angleDifference(desired, ship.angle)) < 0.45;

// 計算目前朝向與目標朝向的夾角誤差,平滑轉動船頭
const error = angleDifference(desired, ship.angle);
const turn = Math.max(-1, Math.min(1, error * 3.5));

這裡控制器並非求解複雜的移動目標截擊二元方程,而是利用相對速度做一次一階外推,即能在微步循環中極為經濟地打出精確的前置量攔截。

24 方位候選落點評估(Candidate-heading Sampling)

如果觸發了緊急避險反射,或是 Jev 選擇了 evadereposition,飛船該往哪裡逃?

這裡在地控制器並沒有進行昂貴的連續路徑射線追蹤,而是採用了高效的幾何候選取樣: 24 方位候選落點評估

let best = -Infinity;
let desired = ship.angle;

// 將 360 度空間均分為 24 個候選航向(每 15 度一個測試方位)
for (let i = 0; i < 24; i++) {
  const a = i * Math.PI / 12;
  // 啟發式測試落點:當前速度漂移 0.45 秒加上 180 像素的固定方向位移
  const point = {
    x: ship.x + ship.vx * 0.45 + Math.cos(a) * 180,
    y: ship.y + ship.vy * 0.45 + Math.sin(a) * 180,
  };
  // 將所有隕石線性外推至 0.7 秒後的位置,計算該落點與所有隕石的最短安全淨距(clearance)
  const clearance = Math.min(...game.rocks.map(r =>
    length(delta(point, { x: r.x + r.vx * 0.7, y: r.y + r.vy * 0.7 })) - r.radius
  ));

  // 評估函數:落點安全淨距越大越好,但大幅轉向會扣分(保持航向穩定性)
  const value = clearance - Math.abs(angleDifference(a, ship.angle)) * 28;
  if (value > best) {
    best = value;
    desired = a;
  }
}
// 只有當船頭轉向已對齊最佳逃逸方位(誤差小於 0.75 徑度 / 約 43 度)時才啟動推進
const thrust = Math.abs(angleDifference(desired, ship.angle)) < 0.75;

這項演算法的核心概念在於評估「預期目的地之安全空間」與「轉向成本」,而不是證明整條飛行動態路徑絕對無礙。每秒鐘執行 120 次這段計算,飛船就能在雜亂無章的隕石風暴中靈活找到開闊空域。

控制器分工與邊界處理

深入檢視控制器實作,會發現兩項有趣的工程細節:

  1. 戰術分支整併:在當前的本機控制器中,evadereposition 共用相同的候選取樣逃逸分支。雖然兩者在 Jev 的模型語意與介面展示上代表不同的戰術意圖(緊急逃亡 vs. 拉開距離重新佔位),但在微觀幾何執行上,尋找最開闊落點的邏輯是一致的。
  2. 目標回退與全面性伺機開火:若 Jev 選擇的目標隕石被提前擊毀,或者模型回傳 none(映射為 null),在地控制器會自動回退鎖定最近的隕石,因此 none 並不會禁止飛船開火。更進一步地,開火檢測會遍歷場上所有隕石:
const fire = game.rocks.some(r => {
  const d = delta(ship, r), t = length(d) / BULLET_SPEED;
  const bearing = Math.atan2(d.y + (r.vy - ship.vy) * t, d.x + (r.vx - ship.vx) * t);
  // 在 580px 射程內,且船頭朝向落入目標角半徑加安全裕度範圍內
  return length(d) < 580 && Math.abs(angleDifference(bearing, ship.angle)) < Math.atan2(r.radius + 7, length(d));
});

只要船頭在旋轉過程中恰好掃過任何一顆隕石的預估前置角,且通過引擎 0.15 秒的射擊冷卻(cooldown),系統就會伺機補槍,絕不放過任何順手消滅威脅的機會。

工程防禦性設計:應對網路延遲與非確定性

把網路 API 嵌入遊戲主循環,最考驗工程師的從來不是理想狀態,而是出問題時如何優雅降級(graceful degradation)。

src/pilot.tsFlightComputer 中,我設計了幾項關鍵的防禦性機制:

世代計數器與過期丟棄(Generation Invalidation)

想像一個情境:飛船在第 1 星區發出了一個決策請求,但在回應傳回前,飛船不幸撞毀重生,或玩家手動按下了暫停。此時如果舊的決策姍姍來遲並被採納,飛船就會執行上一個生命週期的過時指令。

為了解決這個問題,我為 FlightComputer 設計了世代機制:

export class FlightComputer {
  private generation = 0;
  private controller: AbortController | null = null;

  reset(): void {
    // 每次狀態重大變更(重生、換關、暫停、手動接管),立即遞增世代並嘗試中止在途請求
    this.generation++;
    this.controller?.abort();
    this.controller = null;
    this.decision = null;
    this.thinking = false;
  }

  async tick(snapshot: FlightSnapshot): Promise<void> {
    const generation = this.generation;
    // ... 發送 fetch 請求 ...
    const data = await response.json();

    // 如果在等待期間世代已經改變,無條件直接丟棄該回應!
    if (generation !== this.generation) return;
    // ...
  }
}

任何狀態重設都會發送 AbortController.abort() 嘗試中斷網路連線;即便遠端推論無法立即中止,回傳當下只要比對 generation !== this.generation,舊回應就會被立刻拋棄,確保控制權不被幽靈決策干擾。

決策存活時間(Decision TTL)

網路偶爾會出現抖動。如果 Jev 的回應因網路卡頓延遲了 2 秒才抵達,當時的戰場情勢早已截然不同。

在程式碼中我定義了 DECISION_TTL = 4500(4.5 秒)。值得注意的是,決策的時間戳記並非記錄回傳當下,而是記錄發起請求時的起算時間

this.decision = decision;
this.receivedAt = started; // 以請求發起時間 started 作為有效基準點
this.nextAt = Math.max(started + 1200, this.now() + 100);
fresh(): FlightDecision | null {
  return this.decision && this.now() - this.receivedAt < DECISION_TTL ? this.decision : null;
}

這意味著如果一次請求花了 2 秒才返回,它在客戶端的剩餘有效壽命就只剩下約 2.5 秒。如果超過 4.5 秒仍未收到更新,fresh() 便判定決策過期並回傳 null

當決策過期或尚未抵達時,在地控制器會進入明確標記的 LOCAL SAFETY 狀態:維持基本的避難漂移航向,並關閉開火系統,等待 Jev 的下一筆有效決策到來。此外,前端還設有 4 秒的瀏覽器端中斷計時器,而後端 SDK 亦配置了 3.5 秒的逾時限制(timeout: 3500, retry: { maxRetries: 0 }),層層把關避免請求無休止掛起。

請求節流與固定退避

伺服器端與前端互相配合限流:

  • 名義循環節流:正常無干擾運作時,前端下一次請求排定在 Math.max(started + 1200, this.now() + 100),維持約 1.2 秒的節奏。
  • 伺服器入場限制:後端以程序區域變數 busyperformance.now() - lastRequest < 800 作為最小入場間隔防護,避免多重呼叫撞車。
  • 固定時間退避:一旦 API 遭遇錯誤(如配額超限或網路異常),前端會關閉 SDK 的自動重試,直接啟動固定 5 秒的冷卻排程(this.nextAt = this.now() + 5000),並在儀表板即時警示,防止雪崩效應。

人類駕駛優先原則(Human Takeover)

在任何自主駕駛系統中,最重要的一條守則是: 人類隨時擁有最高優先權

src/main.ts 中,無論飛船正由 Jev 執行多麼自信的攔截動作,只要玩家按下左轉、右轉、前進或開火的任何一個按鍵,系統會在按鍵事件中立即切換模式(setMode(false))並重設所有飛行電腦狀態。手動控制在下一個 120 Hz 物理步即時生效,不帶任何拖泥帶水。

結語與架構啟示

這次為了練習 TypeScript 與探索 Jev AI 而動手打造 hello-jev,整個實作過程讓我對即時人機協同系統有了很多深刻的體會。

在當前 AI Agent 的開發浪潮中,許多人常陷入一種迷思:試圖把所有事情(甚至是底層物理運算或連續控制)全部交給大模型解決。一旦模型表現不好,就試圖塞入更長的提示詞或換用更昂貴的旗艦模型。

但打造 hello-jev 的經驗讓我體會到另一條更健康、更接地氣的工程路徑:

  1. 讓模型做它最擅長的事:大模型的核心價值在於語意理解、動態目標權衡與戰術姿態決策。不要讓它做二維矩陣乘法或微積分,那些交給 CPU 的幾行代數運算既便宜又精準。
  2. 語意特徵工程依然關鍵:提供給模型的上下文不應是未加工的原始傾印(raw dump),而是經過幾何轉換、語意豐富的衍生特徵(如相對方位、最近距離預估與接近速度)。輸入越貼近領域認知,模型給出的決策就越精確。
  3. 分層與防衛性設計是系統韌性的基石:結合高頻確定性控制器、反射避險機制、世代失效檢查與嚴格 TTL,才能打造出即便面對網路延遲與偶發故障,依然不會失控的安全混合智慧系統。

透過這個小專案,我一方面更熟悉了現代 TypeScript 的強型別推導與非同步生命週期管理,另一方面也親身體驗了 TypeSafe AI 的結構化查詢在即時決策上的潛力。

這套「雙層分工+確定性防衛」的架構模式,其適用範圍遠遠不止於 Asteroids 這種街機遊戲。在即時金融風控、邊緣物聯網(IoT)控制,或是各類需要人機協同操作的 AI 系統中,這種思考方式都極具參考價值。

下次在思考如何將 AI 引入即時或高互動系統時,不妨想想這艘在隕石群中靈巧穿梭的小太空船——讓 Jev 負責看清星圖,讓在地程式碼穩穩握住操縱桿。