# How I Ran Doom on GitHub Issues
AI-readable version of this post.
Path: /systems/how-i-ran-doom-on-github-issues
Category: Systems
Date: 2026-07-25
Author: Shubham Blogpost
Reading time: 5 min
Description: A story about turning GitHub Issues into a strange but surprisingly coherent game loop.
<iframe width="560" height="315" src="https://www.youtube.com/embed/IIISWIoDfEI?si=I6lieqORHS_Gz2jj" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


## How it started

I did not begin this project with a product goal. I began with a kind of technical itch that shows up when you keep seeing strange engineering demos and they refuse to leave your head. Over a short stretch of time I saw Doom running on places that looked completely wrong for games, including calculators and even pregnancy tests, and each example had the same energy: if you can push pixels and map a few inputs, you can probably make Doom appear. After enough of those projects, I stopped thinking, "that is funny," and started thinking, "what is the most unreasonable interface I already use every day?"

The answer was GitHub Issues. It already has comments, timestamps, history, and a thread model. It is collaborative, scriptable, and deeply text-first. It is also obviously not a game UI, which made the idea interesting. I wanted to see whether an issue could behave like a game session if the backend treated comments as commands and treated the issue body as a frame display. In that moment, the goal became less about playing Doom and more about testing a severe interface constraint.

![Issue session with an early generated frame and the first `w` command comment.](/build-walkthrough/image-3.png)

One thing that helped me commit to the idea was understanding why Doom keeps showing up on unusual hardware and software surfaces. Doom is not "easy" in the sense that game programming is trivial, but the core runtime expectations are small enough that you can port it almost anywhere with disciplined glue code. The renderer can be fed into a buffer, the simulation loop can step at a fixed rhythm, and the input space is compact. When you look at it from systems perspective, Doom is a great candidate for adaptation because the boundary between game logic and platform I/O can be made very thin.

That was where DoomGeneric became decisive for me. DoomGeneric exposes a portability layer where your platform implementation mainly needs to provide input polling, timing, and a framebuffer handoff. The framebuffer handoff function was the breakthrough point in my design thinking. I realized I did not need a browser canvas or native window at all. I only needed to capture each completed framebuffer, encode it as an image artifact, and publish that artifact into markdown. Once I saw that path clearly, GitHub Issues stopped feeling like a joke target and started feeling like a strict but valid presentation layer.

The first prototype was intentionally blunt. A new issue represented a session. A new comment represented an input batch. Before the renderer pipeline was fully stable, I often had threads that only showed a command comment and no session body update yet, which was still useful because it proved input capture and webhook flow were working.


As the loop stabilized, I replaced early placeholder output with a real game frame path so the issue body reflected actual engine output instead of a mock rendering stage.

![Real Doom frame rendered in the issue body, showing in-game scene output.](/build-walkthrough/image-4.png)

Once that was reliable, I added the VizDoom path for practical real-frame operation while keeping the same issue-thread interface.

![Later running session view at tick 3 after accepted commands.](/build-walkthrough/image-7.png)

The next challenge was performance, not correctness. I could replay command history and regenerate state, but that replay model degrades as sessions get longer. Every new command drags all previous commands behind it, and the latency curve becomes obvious fast. If I wanted the GitHub comment loop to feel responsive instead of ceremonial, I needed to stop replaying from zero on every update. That pushed me toward persistent workers where each session keeps live state in memory and only new command batches are stepped.

When I shifted to a persistent DoomGeneric process per active issue, the runtime behavior changed immediately. The system no longer behaved like "generate a demo frame from logs" and started behaving like an actual continuously evolving game session. The issue body was still the same markdown surface, but the backend was now operating on live state instead of historical reconstruction. That separation mattered because it let me keep the weird UI while improving the engine path under it.


![Running issue session with `renderer: vizdoom` and a first real frame at tick 0.](/build-walkthrough/image-6.png)

There was another moment that felt important from a user perspective: restoring a full recognizable Doom frame, including the HUD elements people subconsciously expect. Early images proved transport, but they did not feel like playing Doom. Once the status bar, weapon view, and familiar visual structure appeared consistently, the emotional gap closed. People reading the issue were no longer interpreting abstract state changes; they were reading a game screen that happened to live inside a code-hosting platform.

![Session frame with the full classic Doom HUD visible inside the issue body.](/build-walkthrough/image-8.png)

I also learned quickly that a text-thread UI punishes ambiguity. If a session is paused or stale and comments keep arriving, silent behavior feels broken. I added explicit paused-session handling so the thread itself explains what happened and what to do next. That might sound small, but in this environment the thread is the whole product. There is no hidden client-side state to rescue the experience, so operational clarity has to be visible in plain text near the frame updates.

![Repeated paused-session replies in the thread after inactivity timeout.](/build-walkthrough/image-9.png)


By V4, the architecture was no longer just "webhook in, frame out." It had a clear session lifecycle, per-issue locking, cache-aware fast paths, and a persistent worker route for low-latency frame updates. This is the V4 architecture shape I settled on.

```mermaid
flowchart TD
  U["GitHub User"] --> GH["GitHub Issue Comment"]
  GH --> WH["/webhook"]
  WH --> Q["In-Process Job Queue"]
  Q --> L["Per-Issue Lock"]
  L --> LC["Session Lifecycle"]
  LC --> G["Command Parser + Game State"]
  G --> MC{"Boot/Menu Cache Hit?"}
  MC -->|yes| CPNG["Cached PNG"]
  MC -->|no| SM["Session Manager"]
  SM --> DG["Persistent DoomGeneric Worker"]
  DG --> PNG["Local Frame PNG"]
  PNG --> STORE["Frame Store: Local or S3"]
  CPNG --> STORE
  STORE --> PATCH["GitHub Issue PATCH"]
```

Looking back, the most useful part of this project was not the novelty of "Doom in Issues." It was the architectural pressure created by the constraint. GitHub gave me durable logs, deterministic input chunks, and transparent state transitions, but it also removed every comfort a normal game UI provides. That forced me to think carefully about latency, idempotency, locking, artifact publishing, and error messaging, because every flaw became painfully obvious in the public thread.

![How to play](/build-walkthrough/demo.mp4)

I ended up with a system that still sounds absurd when said out loud, but is technically coherent: comment-driven inputs, per-issue session state, DoomGeneric framebuffer capture, image publication, and issue-body patching as the render surface. The strange part is that this path made me appreciate Doom ports even more. Doom keeps running "everywhere" not because of magic, but because its boundaries are friendly to adaptation when you respect the platform contract. In my case, that contract was not a display driver or hardware panel. It was markdown, webhooks, and a comment box.