~/satyajit

Laya vs Jev: the slower model gets an easier question

mdjsonmcp

2026-09-21 · 22 min · explainer · llm · benchmarks · on-device-inference · architecture · agents

virajbhartiya/laya-vs-jev puts two decision models on the same obstacle course. Laya runs on the machine through MLX. Jev is TypeSafe's hosted API over the network. The course is a Python port of Chrome's offline dinosaur game, pinned to a Chromium commit, with the original constants and collision boxes. Both models get the same kind of question every move — pick one of jump, duck, run — and answer with probabilities. Neither was trained on the game.

virajbhartiya/laya-vs-jev@2854178 · snapshot 2026-09-22
tracked files
164
license
Apache-2.0
branch
main
tests
13 files
source
447.8 kB
commit date
2026-09-21
source by language
Python447.8 kB(70)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-09-22 at 2854178 branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

My first reaction was the cheap one: a real-time game that races local inference against an HTTPS round trip measures the round trip, not the models. It does. The repository says so on line 119 of its own demo document, in a section headed Keeping it fair to a slow model, and says it better than I was going to:

For Jev, at about 370 ms, it is one turn every 22 frames, while the window in which a jump clears an obstacle is only 18 to 28 frames wide. With one chance per obstacle, Jev died at the first or second one even though it picked the best move every time.

Then it ships two mitigations, and then concedes: "What stays unfair is physics." That is a repository that has already had the argument with itself.

So the interesting question is not whether the race is fair. It is what an honest head-to-head measures once you have taken latency out of play as far as it will go. I read the harness, reproduced the arithmetic, and then ran the repository's own planner over its own benchmark snapshots on a Linux box with no models and no API key at all. The answer is that the harness is the experiment, and the thing it varies most is not the model.

The arithmetic, checked

laya_mlx/trex/engine.py:13 sets FPS = 60, so FRAME_MS = 1000 / 60 = 16.67. The round-trip numbers land as advertised:

framesms
One Jev round trip at 370 ms22.2370
The doc's stated jump window18–28300–467
Laya's answer at ~33 ms2.033

The round trip is roughly the whole window. That is the entire story of why one-question-at-a-time kills a hosted model here, and why the fix has to be concurrency rather than speed: you cannot make the window wider, and you cannot make the network shorter, so you buy more chances inside it.

I wanted the window from the physics rather than from the prose. The planner and the engine import nothing but math, sys, random and dataclasses, so they run anywhere. For each obstacle I sweep the takeoff delay and keep the longest contiguous run of delays that survives the 160-frame horizon:

# laya_mlx/trex/planner.py exposes everything this needs.
search = Search(World(snap, 160), (1, 1), 160)
ok, state = [], snap.trex
for d in range(70):
    jumped = search.press(state, "jump", d)
    if search.run_frames(jumped, "run", d, 160 - d) is not None:
        ok.append(d)                       # a takeoff at delay d clears it
    state = search.run_frames(state, "run", d, 1)

Over 616 obstacles that actually need a jump or a duck, across five course seeds, the window is 13 to 26 frames, median 18 — 217 to 433 ms. The doc's 18-to-28 sits about four frames higher than that at both ends: its lower bound is my median. High birds are excluded; the dino runs under them, so their "window" is 31 to 50 frames and would flatter the distribution.

60 fps · one frame = 16.67 ms · window measured on 616 obstacles, cadence read off the harness
00 ms583 ms10167 ms15250 ms20333 ms25417 ms30500 msframestakeoff window616 obstacles, 5 seedsmedian 181326frames per turnLaya~33 ms local answer2 (33 ms)Jev, 6 in flightPilot.stagger6 (100 ms)Jev, 1 in flightone turn per round trip22 (367 ms)
The bar is the window in which pressing jump clears the obstacle ahead, measured with the repository’s own planner over 616 jump-or-duck obstacles on five seeds: 13 to 26 frames, median 18. One Jev round trip at 370 ms is 22 frames, which is wider than the window on 88.5% of them — one chance per obstacle, and often not even that. Six requests in flight move the turn to every 6 frames, narrower than every window in the sample. That is the fix: not a faster model, a denser schedule.

88.5% of jump-or-duck obstacles have a window of 22 frames or fewer. At one request in flight, the majority of obstacles offer a hosted model at most one chance, and nothing guarantees that chance lands inside it. At six in flight the turn comes every 6 frames, which is narrower than every window in the sample. That number is not in the repository, and it is the one that shows the mitigation working.

The two mitigations, precisely

Several requests in flight. Pilot starts one worker thread per in-flight slot, and Pilot.stagger decides how far apart they ask:

@property
def stagger(self):
    # At most four questions per round trip: enough turns, and a hosted API's
    # rate limit is respected. Extra request slots only keep that schedule regular.
    return min(12, max(1, round(max(1.0, self.typical_frames) / min(self.inflight, 4))))

--jev-inflight defaults to 6 and accepts up to 8, but min(self.inflight, 4) caps the schedule at four questions per round trip. At 22 frames of typical latency that is a turn every 6 frames. Slots five and six do not buy more turns; they absorb jitter so the six-frame cadence holds. The README does not say this, and it is the difference between "I raised the flag to 8 and nothing changed" and knowing why.

Lockstep. --lockstep N freezes each game while its own model answers, then plays N frames and asks again. Arena.tick_lockstep simply returns while a pilot is waiting — no physics step, no shield, nothing — so the game is a pure function of its action sequence. cli.build() also forces inflight to 1 for both players in this mode. The flag's own help text is the clearest statement of intent in the repository: "Removes latency from play to compare decisions alone."

Discards. An answer is asked on the premise that the keys stay as they are until it lands. Pilot keeps an epoch counter that increments whenever an applied answer changes the keys, and premise_holds() rejects anything asked in a stale epoch, anything older than the last accepted sequence number, and anything whose predecessor was itself dropped. The last clause is a Python chained comparison — self.bump_seq == decision.pending_seq is not None — which reads as "the answer that ended the old epoch is the one this answer was told about, and it existed", and is easy to misread as an equality against a boolean. Rejected answers are counted in stats.discarded, surface in the report as answers_discarded and appear on screen as skipped. In the repository's own screenshot that counter reads 1074 for Laya and 689 for Jev — the fast player throws away more answers than the slow one, because it changes the keys more often.

The finding: the prompt is a function of latency

Here is the part I did not expect. The question is not fixed. Pilot.work() calls self.timing(), which returns a tuple derived from that player's own measured latency:

def timing(self):
    """(first, gap, period) for Planner.plan, in frames."""
    if self.lockstep:
        return ((0, 0), (self.lockstep, self.lockstep), None)
    landing = (self.latency_frames, self.latency_frames + self.jitter_frames)
    if self.inflight == 1:
        return (landing, landing, None)
    return (landing, landing, self.longest_wait)

That tuple goes straight into Planner.plan(snap, first, gap, period), which decides which actions are safe, which one is best, and — this is the part that matters — how far away the obstacle is said to be:

travelled = sum(obstacle_step(world.speed.get(k, snap.speed), ahead.offset)
                for k in range(1, lo + 1))
distance = int(ahead.x - travelled - (snap.trex_x + TREX_WIDTH))

lo is the player's own latency in frames. So the distance printed in the state line is where the obstacle will be when this player's answer lands. Two players looking at the same physical frame are told two different numbers.

Laya, real time
first=(1,3) gap=(1,3) period=2

Dino runner game. 3 small cacti ahead, 172 px away.

  • jumpSafe. Jumps too early.
  • duckSafe. Crouches; no benefit.
  • runSafe. Waits; acts later. Best.
Three safe options. Answering jump is scored a miss.
Jev, real time, 6 in flight
first=(18,24) gap=(18,24) period=6

Dino runner game. 3 small cacti ahead, 70 px away.

  • jumpSafe. Clears the 3 small cacti. Best.
  • duckUnsafe. Hits the 3 small cacti. Collision.
  • runSafe. Waits; must act soon.
Same frame. Answering jump is scored a hit.
Jev, real time, 1 in flight
first=(18,24) gap=(18,24) period=None

Dino runner game. 3 small cacti ahead, 70 px away.

  • jumpSafe. Clears the 3 small cacti. Best.
  • duckUnsafe. Hits the 3 small cacti. Collision.
  • runUnsafe. Hits the 3 small cacti. Collision.
Two of three marked Collision. The exam is multiple choice with one option.
Both players, --lockstep 6
first=(0,0) gap=(6,6) period=None

Dino runner game. 3 small cacti ahead, 178 px away.

  • jumpSafe. Jumps too early.
  • duckSafe. Crouches; no benefit.
  • runSafe. Waits; acts later. Best.
Latency leaves the prompt, so both players get this one.
One physical frame — snapshot 12 of the sample set the repository’s own planner benchmark builds, seed 17, dino grounded at speed 6.62 — rendered four times by build_question(plan, "labeled"). The distance in the state line is where the obstacle will be when this player’s answer lands, so Laya is told 172 px and Jev 70 px about the same instant. The recommended action is the opposite one. In lockstep the timing tuple is fixed by the flag rather than by the model, and the two players get the same question for the first time.

One frame. Laya is told the cacti are 172 px away and that waiting is best. Jev is told they are 70 px away and that jumping is best. If Laya answers jump it is scored a miss; if Jev answers jump on the same frame it is scored a hit. Neither model is wrong, and neither prompt is wrong — the prompt is doing exactly what it should, which is to describe the world the answer will arrive in. But it means the two players are not taking the same exam.

I put the repository's own benchmark sample through this. scripts/benchmark_trex_planner.py builds snapshots from seed 17 — 122 of them, called three times each for the 366 planner calls its doc reports — and I planned each one twice, once at Laya's timing and once at Jev's, using two of the three regimes that script already hardcodes:

Over 122 snapshots, seed 17
Identical question for both players13.9%
Different distance in the state line95.1%
Different set of safe actions72.1%
Different recommended action23.0%

Where both distances are defined, Jev's is smaller every single time, by 102 to 170 px, median 136. There is no snapshot in the sample where the slower player is told the obstacle is further away.

Which exam is harder

The labeled prompt marks exactly one option Best., marks the other safe ones Safe., and marks unsafe ones Unsafe. … Collision. So "picked the planner's best move" is a reading-comprehension score, which the docs say plainly: "it measures how fast and how reliably each model reads a labelled situation, not whether it can work out dinosaur physics."

The difficulty of that reading task is set by how many options are struck out before the model sees them.

122 snapshots · seed 17 · the same frames, four timing tuples
0 safe1 safe2 safe3 safebest effortLaya, real time(1,3) frames, 2 in flight71.3%16.4%Both, --lockstep 6(0,0) frames, 6 per decision14.8%13.9%66.4%14.8%Jev, real time(18,24) frames, 6 in flight24.6%35.2%20.5%19.7%36.9%Jev, one at a time(18,24) frames, 1 in flight27.9%27%25.4%19.7%60.7%
Same 122 frames, four latency tuples. Laya is usually told every action is safe and asked to pick the one line that also says Best; Jev is usually told two of the three collide. A slower player gets a more forced question, and a more forced question is easier to answer correctly. The right-hand column is the planner’s own robust=False rate — how often it could prove nothing at all and guessed — which is where the slow player’s deaths come from.

Laya is shown all three actions as safe on 71.3% of snapshots and has to pick the one line that additionally says Best — where the three lines are "Jumps too early", "Crouches; no benefit" and "Waits; acts later". Jev sees two of three marked Collision on 59.8%, and sees all three safe on 19.7%. A more forced question is an easier question.

This is the answer to the number in the repository's hero screenshot.

A screenshot of the arena window at the end of a match. Two lanes, Laya on the left in blue and Jev on the right in orange, both showing a dinosaur mid-course with an 'Arrival shield saved it' notice. Both scores read 1,175 and the header says MATCH 0 to 2, Jev wins the match. Laya's stats: answer time 49 ms, model 24 ms, best move 71 percent, live saves 774 with 142 model vetoes, cost free. Jev's: answer time 383 ms, model 361 ms, best move 89 percent, live saves 92 with 0 model vetoes, cost 0.0195 dollars. At the bottom, a score-over-time chart, an answer-time-per-move chart, and two head-to-head bars: answer time median 49 ms against 383 ms, and BEST MOVE PICKED 71 percent against 89 percent. The footer reads 'Course designed by Jev: 192 obstacles so far, seed 7'.
The only head-to-head result the repository ships in full. Read the bottom-right pair: BEST MOVE PICKED, 71% for Laya and 89% for Jev, drawn as a direct comparison. Those two percentages were scored against prompts built from each player's own latency — the 89% belongs to the exam where two of three options were struck out before the model read them. Note also the veto counts, 142 for Laya and 0 for Jev: Jev never once proposed an action the planner had marked unsafe, which is the same fact as its 89% stated a second way. (virajbhartiya/laya-vs-jev, docs/assets/trex-arena-window.png, commit 2854178, Apache-2.0, flattened onto white for dark mode.) Used under the Apache License 2.0, with the project NOTICE and the Chromium BSD-3-Clause notice for the game sprites committed beside the image at /articles/laya-vs-jev/APACHE-2.0-LICENSE.txt, /articles/laya-vs-jev/NOTICE.txt and /articles/laya-vs-jev/CHROMIUM-BSD-3-CLAUSE.txt.

A prompt that says Safe about a collision

One thing falls out of the planner's fallback path that the README does not surface. When no action survives every possible landing time, Planner.plan downgrades to best effort and sets robust = False. If even the relaxed search finds nothing, plan.safe stays all-False while plan.best keeps whatever plan_once chose. build_question then formats the options like this:

criteria = {
    a: f"Safe. {notes[a]}. Best." if a == best
    else f"Safe. {notes[a]}." if plan.safe[a]
    else f"Unsafe. {notes[a]}. Collision."
    for a in ACTIONS
}

The a == best branch is checked first and does not consult plan.safe, so the recommended option is labelled Safe. unconditionally. On the 30 of 122 snapshots where Jev's timing leaves nothing safe, the prompt reads:

state:    Dino runner game. The dino is in the air. Large cactus ahead, 0 px away.
  jump: Unsafe. Lands on the large cactus. Collision.
  duck: Unsafe. Drops onto the large cactus. Collision.
  run:  Safe. Lands on the large cactus. Best.

Safe. and Lands on the large cactus. in the same line. All 30 read this way. At Laya's timing it happens on 14. It is a formatting bug rather than a physics bug — the planner knows perfectly well the situation is lost, and the report counts it in best_effort_decisions — but it is a contradictory string handed to a model whose entire job is to read the string, and it lands on the slow player twice as often. The robust flag is right there in the Plan; the format call just never asks.

Related: build_question's docstring says labeled "never names the answer". It does not name it in the state — that is what --prompt guided adds — but it appends Best. to the winning option's criteria in both modes. The distinction is real; the docstring is a word short.

What is not in the repository

Thirty files in benchmarks/results/, inherited from mizorewww/laya-mlx and credited at README.md:62. None of them mentions Jev. Those are Snake and single-question latency results for the MLX runtime, and the README says so: "those latency measurements are not T-Rex gameplay measurements."

What does exist is three head-to-head tables in docs/TREX_DEMO.md prose and the screenshot above. What does not exist is any of it in a form you can re-analyse. The document states it outright: "Raw validation recordings and videos are local artifacts and are not bundled with the repository." No artifacts/trex/*.json, no .jsonl recording, no crash sidecar. The harness is published; the evidence is a picture of a scoreboard and some tables.

The document is also disciplined about what it will let those tables mean. The historical M3 run carries "The single-request schedule and host stalls prevent using these results to judge the current system." The latest match carries "This is a show result, not evidence of better model skill" and "The host dropped 5.83 seconds of stalled time; do not treat it as a clean latency benchmark." The smoke test carries "A single live run is a smoke test, not evidence of a stable winner."

So: anyone quoting a Laya-vs-Jev scoreline is quoting their own run, on their own Mac, on their own network, against a hosted model whose latency they do not control. The repository never claims otherwise. This is the caveat that matters more than anything else in this piece, and it is the one most likely to get lost when a screenshot travels.

One more structural note, since it is easy to miss: --course jev is the default, so by default one of the two competitors designs the obstacles both must clear. The design is shared, seeded and computed ahead of play, so it is not a latency advantage, and the doc reports Jev's difficulty rising with speed (rank correlation 0.47) against Laya's 0.19. It is still a competitor setting the exam. --course random removes it, and any comparison I would publish would use it.

Does this measure decision quality at all?

Partly, and only in one mode. Here is the exact state of it.

In real time, no. The per-player agreement_with_planner in the report is scored against a rubric built from that player's own latency, and I have measured how far apart the two rubrics are: the same question on 13.9% of snapshots, a different recommended action on 23.0%. The live window draws the two rates as a head-to-head bar (app.py:602, "BEST MOVE PICKED"), and that bar is comparing two different exams.

In lockstep, yes — and it has been run once. timing() returns ((0,0),(N,N),None) for every player, so the tuple comes from the flag instead of the model and the rubric is shared. The doc's historical table has it:

Lockstep, 6 frames per decisionLayaJev
Deaths00
Picked the planner's best move74%100%
Shield saves300

That is the closest thing to a decision-quality comparison anyone has, and it is published without a decision count, without a report file, inside a section the document itself labels superseded. It is also not quite clean: each pilot freezes independently, so the two games diverge at the first differing action and the state distributions stop matching.

Per-decision agreement between the models is not recoverable from anything the harness emits. I checked. Pilot.think computes agreed = same_effect(proposed, plan.best, plan.airborne) and the recording's per-frame panel carries p (the probability vector), prop, exec, veto and a running agree ratio — but not best per decision, not the question, and no join key onto the other player's decision at the same game frame. You could difference agree × n between frames to recover most per-decision hit/miss flags. You still could not answer "on the frames where both models were asked the same thing, how often did they pick the same action", because in real time there are almost no such frames, and in lockstep the games have drifted apart.

There is also no way to give both players the same amount of work. --duration, --round-seconds and --rounds are all wall-clock, and in lockstep a player's game advances only while it is not waiting: six played frames per decision against a two-frame wait for Laya and a twenty-two-frame wait for Jev, so roughly 6/8 of real time against roughly 6/28. The report exposes exactly this as game_time_ratio. Ninety seconds on the clock is about seventy seconds of game for one player and about twenty for the other. There is no --decisions N.

The run I would actually do

Do not race them. The race is not needed, and the pieces to avoid it are already in the repository and already model-free:

from laya_mlx.trex.planner import Planner, snapshot
from laya_mlx.trex.backends import build_question, create, decide
 
snaps = build_snapshots(seed=17)          # exactly as scripts/benchmark_trex_planner.py does
planner = Planner()
backends = {k: create(k) for k in ("laya", "jev")}
 
for snap in snaps:
    # ONE timing tuple for both players: latency is a constant, not a variable.
    plan = planner.plan(snap, (0, 0), (6, 6), None)
    state, questions = build_question(plan, "labeled")
    row = {"best": plan.best, "safe": plan.safe, "state": state}
    for name, backend in backends.items():
        row[name] = decide(backend, state, questions).probabilities  # identical bytes
    print(row)

Fix one timing tuple. Generate the question set once. Ask both models the same strings. Then you get a per-decision agreement rate, a per-decision comparison against plan.best, and a paired probability distance — on identical inputs, with no game, no clock, no network in the measurement, and it is reproducible by anyone with a key because the question set is text you can commit. It is about twenty lines against the existing API, and it is the first direct quality comparison between these two models that would mean anything.

If you must do it in the game: --lockstep 6 --unassisted --course random --course-style original --seed 7, one player per process, and tune --duration per player until the two reports show equal decisions. The shields off matter — with --unassisted the action sequence is the model's alone, so the run is deterministic and someone else can reproduce it exactly.

What I would take away

Related reading on this family: the browser piece, which refused a local-versus-hosted latency comparison for this exact reason; the Apple silicon ports, whose benchmark suite this repository inherits; and what a decision model cannot do, which is the architectural case for why none of these models can reason about the game in the first place.

What would change my mind

6 claims above, and what would falsify each

  1. The two players are handed the same question on only 13.9% of the repository's own 122 benchmark snapshots.

    Build the snapshots the way scripts/benchmark_trex_planner.py does (seed 17, invincible, a jump every 39 frames, a snapshot every 31 while obstacles are on screen), then compare build_question(planner.plan(snap, (1,3), (1,3), 2), "labeled") against build_question(planner.plan(snap, (18,24), (18,24), 6), "labeled") on each. I get 17 of 122 identical. It needs no MLX and no API key: engine.py, planner.py and backends.py import only math, random, os, queue, threading, time, dataclasses and pathlib at module scope, and MLX is imported lazily inside LayaBackend.serve. On a machine without MLX you have to load them as a package of their own rather than through laya_mlx/__init__.py, which pulls in Agent and therefore mlx.core — copying laya_mlx/trex/ out with its relative imports intact is enough. If a different sample set or a different pair of timing tuples gets close to parity, the divergence is an artifact of the two regimes that script hardcodes rather than a property of the harness, and the strength of my claim goes with it.

  2. The BEST MOVE PICKED bar compares two rates scored against different rubrics, so Jev's 89% against Laya's 71% is not evidence of better decisions.

    Run --lockstep 6 — where timing() returns the same tuple for every player — and compare the two agreement_with_planner figures from the report. If the gap survives at a comparable decision count and a comparable number of game frames per player, the rubric was not carrying it and the real-time bar was closer to fair than I am saying. The repository's own lockstep table already shows a gap (74% against 100%), which is why I have called this partly open rather than settled; what would move me is that table with an n and a committed report file.

  3. The takeoff window is 13-26 frames, and one serial Jev round trip is wider than it on 88.5% of jump-or-duck obstacles.

    Sweep the takeoff delay per obstacle with Search/World at a 160-frame horizon, on seeds 3, 7, 11, 17 and 23, excluding high birds. I measure a median of 18 frames over 616 obstacles. A longer horizon, --course-style original instead of the staged default, or counting the high bird would all move it — the high bird alone runs 31 to 50 frames and drops the share to 79.2%. If the window is genuinely 18 to 28 as the doc says, the share below 22 frames falls and my "one chance per obstacle" framing is too strong, though the conclusion about concurrency does not change.

  4. No machine-readable head-to-head result is committed, so every published Laya-vs-Jev scoreline is somebody's single run.

    git grep -Ili jev returns 13 of 164 tracked files, and benchmarks/results/ — 30 files — is not among them. docs/TREX_DEMO.md says raw recordings and videos are not bundled. If a later commit adds an artifacts/trex/*.json, a .jsonl recording or a CI job that runs the arena, this is out of date and the scorelines become checkable, which is the outcome I want.

  5. The best-effort prompt labels a doomed action 'Safe. … Best.', on 30 of 122 snapshots at Jev's timing.

    build_question's dict comprehension checks a == best before it checks plan.safe[a], so the recommended option is prefixed Safe. whether or not the planner believes it. Every zero-safe snapshot I generated reproduces it. If a later commit reorders those branches, or if plan.best is guaranteed safe on some path I have missed, this is fixed or wrong — note that plan_once does contain a guard, if not safe[best] and any(safe.values()), which by construction cannot fire when nothing is safe.

  6. Raising --jev-inflight past 4 buys no extra turns.

    Pilot.stagger divides by min(self.inflight, 4), so 6 and 8 produce the same stagger as 4 at any latency. Instrument stagger across --jev-inflight 1..8 and print it; if the values keep falling past 4, I have misread the expression. The weaker claim — that the extra slots still help by absorbing jitter, per the code's own comment — is not something I have measured at all.


Read at virajbhartiya/laya-vs-jev commit 2854178, 164 tracked files. Repository Apache-2.0; game rules, sprites and sounds from The Chromium Authors under BSD-3-Clause. Every planner measurement in this piece was computed locally from that commit's laya_mlx/trex/engine.py, planner.py and backends.py; no model was loaded and no API was called.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Laya vs Jev: the slower model gets an easier question", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026layavsjev,
  author = {Satyajit Ghana},
  title  = {Laya vs Jev: the slower model gets an easier question},
  url    = {https://ai.thesatyajit.com/articles/laya-vs-jev},
  year   = {2026}
}
share