nilcheck.dev
7 min read

Putting Doom inside a Rails app inside a desktop app

A tutorial on running Doom inside Turbo Desktop. Nothing useful, but I had a lot of fun.

I wanted to make a fun demo for this week's presentation at tiny ruby #{conf} | Helsinki Ruby. And I kept thinking about all the fun I had with my dad playing Doom when I was little, and how I wanted to play a bit again while on the train or travelling.

Before I start the tutoria,  I want to give credit where credit is due, and to thanks to all the hardworking devs, because at the end of the day I'm just running Doom on Rails and let Turbo Desktop be a nice shell, so let's go over the 3 pieces I'm using:

  • Doom itself by Id Software, thank you for making this awesome game
  • Chocolate Doom is the source port, and in their own words it "is a Doom source port that accurately reproduces the experience of Doom as it was played in the 1990s." Started by Simon Howard (fragglet) and kept alive by a long list of contributors.
  • doom-wasm by Cloudflare is the WebAssembly build, from their README: "This is a Chocolate Doom WebAssembly port with WebSockets support.".

If you enjoy this, go check their repos and give some love.

So, what we are actually doing?

If you didn't read my previous post, the short version is: Turbo Desktop is a thin native shell built on Tauri that points the operating system's own webview at your running Rails app.

Our plan to start playing doom is:
  1. Get a version of Doom that runs in a browser (WebAssembly).
  2. Serve it from Rails like any other static file.
  3. Open it in Turbo Desktop.
That's kinda it. Let's go.

Step 1: Doom, but in WebAssembly

I'm not going to port Doom to WebAssembly myself, cause smarter people already did (and thank you guys for that!). The repo has build scripts (./scripts/clean.sh and ./scripts/build.sh) and needs Emscripten, automake and the SDL2 libraries installed. If you don't feel like installing an entire C toolchain for a meme, that is fair, the build only produces two files you care about:
  • websockets-doom.js — the Emscripten loader
  • websockets-doom.wasm — Doom itself
Plus you need a WAD file, which is the game data. The repo does not ship one and neither will I, but the shareware doom1.wad has been freely distributable since 1993, and if you want to be extra clean about licensing, Freedoom is a fully free replacement that works with the same engine

Step 2: Serve it from Rails

Here is where we need to pay a bit of attention, .js loader fetches the .wasm file by name, and fingerprinting would break that relationship. So no, we go to public/, cause public/ is for exactly this:
public/
└── doom/
    ├── websockets-doom.js
    ├── websockets-doom.wasm
    ├── doom1.wad
    └── default.cfg


Then a route and a controller that does nothing:
# config/routes.rb
get "/doom", to: "doom#show"

# app/controllers/doom_controller.rb
class DoomController < ApplicationController
  def show
  end
end


Now the view. The important parts here are the <canvas> and the Module object, which is how you configure an Emscripten program before it starts. I'm preloading the WAD and the config into Emscripten's virtual filesystem so Doom finds them where it expects. The two lines of text under the canvas are not decoration, the first one will save you from thinking the game is frozen when it is just not focused:
<%# app/views/doom/show.html.erb %>
<div data-turbo="false" class="doom">
  <canvas id="canvas" tabindex="0" oncontextmenu="event.preventDefault()"></canvas>

  <p class="doom__hint">Keyboard events will be captured as long as the DOOM canvas has focus.</p>
  <p class="doom__keys">
    <kbd>↵</kbd> start · <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> move ·
    <kbd>ctrl</kbd> shoot · <kbd>space</kbd> open doors · <kbd>alt</kbd>+arrows strafe
  </p>

  <script>
    var Module = {
      canvas: document.getElementById("canvas"),
      arguments: ["-iwad", "doom1.wad", "-window", "-nogui", "-config", "default.cfg"],
      preRun: [function () {
        FS.createPreloadedFile("", "doom1.wad", "/doom/doom1.wad", true, true);
        FS.createPreloadedFile("", "default.cfg", "/doom/default.cfg", true, true);
      }],
      print: function (text) { console.log(text); }
    };
  </script>
  <script async src="/doom/websockets-doom.js"></script>
</div>


Run bin/rails server, go to http://localhost:3000/doom in a normal browser, click the canvas, and you should be shooting imps. If you are, Rails is done. Everything after this is the desktop part.

Before we continue, check these 3 things

Turbo Drive. Your Rails app has Turbo, cause that's the whole reason you'd use Turbo Desktop. Turbo Drive is very happy to cache your Doom page and restore it later with a canvas that has no WebAssembly attached to it anymore. That's why the wrapper has data-turbo="false", and if you link to /doom from somewhere else, put data-turbo="false" on the link too, so it's a real full page load. Doom is a program, not a page, treat it like one.

The .wasm MIME type. WebAssembly.instantiateStreaming refuses to load anything that is not served as application/wasm. Rack has known about .wasm for years so Rails serves it correctly out of the box, but if you put something in front of Rails (nginx, a CDN, your own static file handler), check it. Emscripten does fall back to a slower path if the MIME type is wrong, but it will tell you in the console and you will feel bad.

Content Security Policy. If your app has a CSP configured, which it should, WebAssembly is blocked by default under script-src. You need to allow it:
# config/initializers/content_security_policy.rb
policy.script_src :self, :wasm_unsafe_eval

'wasm-unsafe-eval' is a lot more polite than 'unsafe-eval', it allows compiling WebAssembly without opening up eval() for JavaScript, and it is supported in the webviews Turbo Desktop runs in.


Step 3: Open it in Turbo Desktop

If you already have a Turbo Desktop app, skip to the path configuration. If not:
npx turbo-desktop new doom-demo

# Gemfile
gem "turbo_desktop-rails"

bundle install
rails generate turbo_desktop:install

Then point the shell at your Rails server, and while you are there, let the app start Rails for you so the demo is one double-click instead of two terminals:
// desktop/turbo-desktop.config.json
{
  "server_url": "http://localhost:3000",
  "app_name": "DOOM",
  "server": {
    "command": "bin/rails server",
    "directory": ".."
  }
}

Now cargo tauri dev (or turbo-desktop dev) and navigate to /doom. It works. That is the demo. Congrats!



If you want to make it look better

Doom has a fixed aspect ratio and it looks sad squished into whatever size your task manager window happens to be. Turbo Desktop uses path configuration, the same JSON, last-match-wins idea from turbo-ios and turbo-android, to decide how a URL gets presented. So we tell it that /doom deserves its own window:
{
  "rules": [
    {
      "patterns": ["/"],
      "properties": { "presentation": "default" }
    },
    {
      "patterns": ["/doom$"],
      "properties": {
        "presentation": "new_window",
        "title": "DOOM on Rails",
        "width": 1280,
        "height": 800
      }
    }
  ]
}

The rules are served by your Rails app at /turbo-desktop/path-configuration, the installer wires that up for you. Now a link to /doom from anywhere in the app opens a separate 1280×800 window with Doom in it, and your actual app keeps living in the main window, which is honestly how I'd want it if this was a real feature.
And cause we are here anyway, a tiny bit of Rails so the page itself admits what it is, but only when it's inside the desktop app:
<%= turbo_desktop_only do %>
  <p class="doom__footer">
    Served from <code>public/doom/</code> by Rails — the native window is one path-configuration rule.
  </p>
<% end %>

That footer only renders when the request comes from the desktop app, in a browser tab it disappears.

And that's pretty much it, now enjoy an overcomplicate way of playing Doom, and go shoot some monsters!

Links
  • Turbo Desktop: https://github.com/aguspe/turbo_desktop
  • Chocolate Doom (the source port, GPL): https://www.chocolate-doom.org/ and https://github.com/chocolate-doom/chocolate-doom
  • Cloudflare's doom-wasm (the WebAssembly build, GPL): https://github.com/cloudflare/doom-wasm
  • Freedoom (free WAD): https://freedoom.github.io/
  • Emscripten Module object and FS.createPreloadedFile: https://emscripten.org/docs/api_reference/module.html and https://emscripten.org/docs/api_reference/Filesystem-API.html
  • Rails CSP configuration: https://guides.rubyonrails.org/security.html#content-security-policy
  • 'wasm-unsafe-eval' in the CSP spec: https://www.w3.org/TR/CSP3/#unsafe-eval-usage
  • Hotwire Native path configuration (the thing Turbo Desktop copies): https://native.hotwired.dev/reference/path-configuration
  • tiny ruby #{conf}: https://helsinkiruby.fi/tinyruby/