Doom deathmatch between Laya and Jev

rythmn1111Sep 212 stars

Two System One models fight a real Doom deathmatch. Laya (322M, open weights, local MLX) vs Jev (TypeSafe hosted). Same state, same typed questions, same shield — only the model differs.

rythmn1111/doom-warReadme
# ⚔️ DOOM WAR — Laya vs Jev

**Two System One models fight a real Doom deathmatch.**
Same state. Same questions. Same shield. Only the model differs.

**[▶ Watch the full 26s run (1080p)](media/doom-war.mp4)**

---

## What this is

Two models of the same *class* — **System One models**, which return typed
decisions and calibrated probabilities instead of text — driving two players in
one live Doom deathmatch against each other.

| | **Laya** | **Jev** |
|---|---|---|
| what | open weights, 322M params | TypeSafe's hosted `jev-1.13` |
| where | local, on an Apple M4 via MLX | over the network |
| cost | free, fully offline | $0.042 / MTok input |
| output tokens | 0 | 0 |

Neither model ever sees a pixel. Deterministic code reads the game state,
compresses it to short prose, and asks each model the **same** typed questions.
The model picks a macro; code turns that into Doom inputs.

```
ViZDoom state ──► observation ──► typed questions ──► model ──► macro ──► buttons
                    (code)          (identical)                  (code)
```

That split is deliberate, and it is what TypeSafe's own docs recommend:
*offload computation, filter data, ask one well-scoped thing at a time.*

---

## The result

```
                        LAYA         JEV
  rounds won               5           1
  deaths                   1           5
  decisions              264         136
  latency p50       244.1 ms    399.8 ms
  overridden           88.3%       30.7%
  tokens in           414,426     132,875
  cost                  free   $0.005581
  ───────────────────────────────────────
  WINNER: LAYA   5 - 1
```

**Laya won on volume.** Roughly twice the decisions in the same wall clock, so
it re-aimed and re-fired far more often. **Jev needed a third of the
corrections** — its individual calls were clearly better — but at ~400 ms each
it was acting on staler information every time it pulled the trigger.

Run `--mode equal` and that volume advantage disappears entirely. Full
telemetry in [`results/match.json`](results/match.json).

---

## How it works

### The arena

Generated at runtime as a **UDMF PWAD** by [`doomwar/mapgen.py`](doomwar/mapgen.py)
— no map editor, no node builder. ZDoom (which ViZDoom is built on) compiles
BSP nodes at load time, so the generator only has to emit `TEXTMAP`.

- An empty **800×800** square box. Four linedefs. No cover.
- Spawns in opposite corners, **877 units apart, in line of sight from tic one**.
- Two monsters in each of the other two corners — pressure without standing
  between the fighters.
- Mirrored kit per side, contested chaingun in the middle.

### The questions

Up to seven per decision, identical for both models, built from what actually
exists in the world so neither can pick something that isn't there:

| id | type | asks |
|---|---|---|
| `goal` | choice | hunt the rival · fight monsters · heal · restock · upgrade · advance |
| `target` | choice | which enemy is most dangerous |
| `turn` | choice | face the rival · face the threat · hold aim · sweep |
| `movement` | choice | close in · hold · back off · strafe · advance |
| `trigger` | choice | hold the trigger down, or not |
| `dodge` | choice | carry on · break left · break right |
| `danger` | noul | about to take serious damage? |

The state also carries **spatial senses** sampled from the depth buffer
(`space: {ahead: wide open, left: tight, right: blocked}`) and an always-on fix
on the rival, flagged `LINED UP IN THE SIGHTS` when it sits inside a 25° cone
and in range.

### The shield

A deterministic layer between the answer and the engine, modelled on the cycle
shield in `laya-mlx`'s Snake demo. It blocks a shot with no ammo, no target, or
a target outside a 30° cone; redirects a move that would walk into a wall; and
breaks a fighter out of geometry it has been grinding for 12 decisions.

**Every correction is counted and shown live as `OVERRIDDEN %`** — how often
deterministic code had to save the model from itself. It is one of the more
revealing numbers in the run. `--no-shield` removes it.

### Match structure

One continuous deathmatch. **Every frag is a round; first to five wins.** No
restarts, no dead air. If nobody scores for 10 s the goal list narrows to
hunt-or-fight; at 18 s the objective is fixed outright. Both fighters flip at
the same instant, so a stalled fight always resolves.

### Timing modes

| | `--mode realtime` *(default)* | `--mode equal` |
|---|---|---|
| engine clock | pinned to Doom's 35 tics/s | advances only when both have decided |
| decisions | each model gets as many as it can | identical count for both |
| measures | judgment **×** speed | judgment alone |
| handicap | a slow model acts on stale state | none |

Both keep the two engines in lockstep on game tics, so neither fighter ever
gets free frames.

---

## Measured on this machine

Apple M4 · 8 GPU cores · 16 GiB · macOS 15.7.3. Identical state, identical
5-question payload:

| | Laya (local) | Jev (hosted) |
|---|---:|---:|
| latency p50 | **144 ms** | 610 ms |
| latency p95 | **145 ms** | 664 ms |
| cost / 1000 decisions | **free** | $0.039 |
| scaling | +28 ms per question | +19 ms per **nine** questions |

Two caveats that matter:

1. **Jev's latency here is dominated by network round trip**, not by the model.
   Even a single question costs ~395 ms from this location. TypeSafe documents
   70–500 ms end to end; this sits at the top of that band. Closer to their
   region it would be lower.
2. **Jev's answers were better.** With nothing in sight it said `hold_fire` at
   confidence 1.00 and `danger` 0.28. Laya said `fire` at 0.78 and `danger`
   0.94 — both wrong. Laya is a 322M open-weight model answering a question
   nobody trained it for. That gap is exactly what `OVERRIDDEN %` measures.

Jev is also nearly flat in question count while Laya is linear, so Jev would
overtake Laya on wall clock somewhere past ~15 questions per decision.

---

## Reproduce it

**Requirements:** Apple Silicon Mac, macOS 14+, Python 3.11 or 3.12,
[uv](https://github.com/astral-sh/uv), and `ffmpeg` if you want to render video.

```bash
git clone https://github.com/rythmn1111/doom-war.git
cd doom-war

uv venv --python 3.12
uv pip install -e '.[laya,render]'
```

**Get the Laya weights** (~660 MB, open, no account needed):

```bash
uv run hf download aac6fef/laya-multilingual-mlx \
  --local-dir models/laya-multilingual-mlx
```

**Set your Jev key** — grab one at [console.typesafe.ai/keys](https://console.typesafe.ai/keys):

```bash
export TYPESAFE_API_KEY="your-key-here"
```

> A `jev.txt` file in the repo root also works and is gitignored — but the
> environment variable is the safer habit.

**Fight:**

```bash
uv run doom-war --rounds 5 --save-frames frames --record telemetry.jsonl
```

The arena WAD is generated on first run. A 1920×1080 dashboard opens, scaled to
fit your display; frames are always captured at true 1080p regardless of screen
size. Press `Q` or `Esc` to stop early.

**Render the video:**

```bash
./render.sh frames out/doom-war.mp4
```

### Useful flags

| flag | default | does |
|---|---|---|
| `--rounds N` | `5` | frags needed to win the match |
| `--mode equal` | `realtime` | give both fighters the same decision count |
| `--no-shield` | off | execute raw model calls, no corrections |
| `--seconds N` | `240` | hard cap on match length |
| `--seed N` | `7` | arena and motor seed |
| `--model PATH` | bundled | a different Laya checkpoint |
| `--save-frames DIR` | off | true 1080p PNGs for a clean render |
| `--fps N` | `30` | frame capture rate |

---

## What this does and does not show

It **does** show two typed-decision models driving a real-time game loop from
identical structured state, with real probabilities, real measured latency,
real cost, and every deterministic override counted on screen.

It **does not** show either model reasoning about Doom from raw pixels. A
deterministic planner does the geometry, visibility and pathing and hands over
short descriptions; the models rank options. Neither was trained on Doom.
Nothing here is a claim about either model's general capability, and the
latency comparison measures *this Mac on this network*, not a datacentre.

No frames are simulated, smoothed, or replayed. `telemetry.jsonl` records every
decision made; `results/match.json` is the match summary.

Bugs worth knowing about, if you build something like this

Four things silently broke this build, in rough order of how long they cost:

1. **`TURN_LEFT_RIGHT_DELTA` is positive-is-right**, while a bearing computed
   with `atan2` is positive-is-left. Without the sign flip every aim command
   turns *away* from the target. A dedicated aim bot got on target 6 tics out
   of 700. One character fixed it and the fire rate tripled.
2. **`sv_noautoaim 1` with no pitch control** means no hitscan shot can ever
   land. Doom's vertical autoaim is doing real work; if your motor never
   touches `LOOK_UP_DOWN`, leave autoaim on.
3. **Applying a turn delta every tic** while the observation only refreshes
   every N tics overshoots by N×. Give a decision a turn *budget* and spend it
   down across the tics that follow.
4. **`label.object_id` ↔ `object.id`** is how you match the visible-actor list
   to world objects. Matching on rounded coordinates silently fails. And the
   label buffer misses actors that are very close or partly off-frame — use
   geometry as the reliable signal.

Also: `get_game_variable()` does not refresh until you call `get_state()`, and
`KILLCOUNT` is a shared level counter in a netgame, not per player.

---

## Project layout

```
doomwar/mapgen.py       generates the arena as a UDMF PWAD
doomwar/observe.py      ViZDoom state ─► compact prose observation
doomwar/schema.py       the question set, shared by both fighters
doomwar/backends/
  ├─ base.py            the Verdict/Backend contract
  ├─ laya.py            local MLX inference
  └─ jev.py             hosted TypeSafe API, pooled connection
doomwar/motor.py        answers ─► buttons, plus the safety shield
doomwar/fighter.py      one fighter process: engine + model + loop
doomwar/arena.py        spawns host + join, keeps them in lockstep
doomwar/ui.py           the 1920×1080 live dashboard
render.sh               captured frames ─► MP4 + GIF
```

Two processes, because ViZDoom multiplayer needs one engine instance per
player. Laya hosts, Jev joins; frames and telemetry flow back over a queue to
the dashboard process.

---

## Credits

- Doom engine via **[ViZDoom](https://github.com/Farama-Foundation/ViZDoom)** (MIT)
- Game content from **[FreeDoom](https://freedoom.github.io/)** (BSD) — free and
  libre, no commercial WAD required
- **Laya** weights by [Convai Innovations](https://github.com/NandhaKishorM/laya),
  MLX port by [laya-mlx](https://github.com/mizorewww/laya-mlx) (Apache-2.0)
- **Jev** and the System One model class by [TypeSafe AI](https://typesafe.ai)

Licensed MIT — see [LICENSE](LICENSE). Doom is a trademark of id Software; this
project ships no id Software assets.
Preview of Doom deathmatch between Laya and Jev
See all

雨夹雪❄️

@mizorewww · Sep 20

介绍比Jev快50倍,在你设备上跑的laya-mlx!

只在你的设备上占用最高1G内存

Laya是一个开源的类似于Jev的,基于文本输出概率的分类系统

我将其移植到MLX,并且做了一些性能优化!

视频中就是这个模型在我的本地M3Max上玩贪吃蛇

这个模型能够以每秒决策60次的速度玩贪吃蛇!

atomic.chat

@atomic_chat_hq · Sep 21

Local Laya moggs Jev at @grok 4.7-built Tetris 🧩

An open-weights System One model called Laya, beat cloud-based Jev at playing Tetris by making decisions 11 times faster, running locally on a 16GB MacBook Air!

Run AI models locally ->

Viraj Bhartiya

@heyxviraj · Sep 21

i made Jev and Laya play the chrome dinosaur game against each other

except they’re actually controlling the dinosaurs.

Laya runs locally on my Mac, Jev runs over an API.

same track, same physics, completely different latency.

they make their own decisions, dodge obstacles…