├── README.md ├── dev.py ├── doom.js ├── doom.wasm ├── glue.js ├── index.html ├── loading.png └── preview.png /README.md: -------------------------------------------------------------------------------- 1 | ## :joystick: DOOM via Checkboxes 2 | > My blog post: [DOOM Rendered via Checkboxes](https://healeycodes.com/doom-rendered-via-checkboxes) 3 | 4 | 5 | 6 |  7 | 8 | 9 | 10 | [Play it now](https://healeycodes.github.io/doom-checkboxes/) (desktop Chrome/Edge only). 11 | 12 | ## The Pitch 13 | 14 | > I don't think you can really say you've exhaused this until you can run DOOM rendered with checkboxes. 15 | 16 | — a commenter wrote [on Hacker News](https://news.ycombinator.com/item?id=28826839) 17 | 18 | 19 | 20 | Bryan Braun gave us [Checkboxland](https://www.bryanbraun.com/checkboxland/), a unique library for rendering text, shapes, and video, via a grid of checkboxes. 21 | 22 | Id software gave us [DOOM](https://en.wikipedia.org/wiki/Doom_(franchise)). 23 | 24 | Cornelius Diekmann gave us [DOOM via WebAssembly](https://github.com/diekmann/wasm-fizzbuzz). 25 | 26 | Today, I'm pleased to stand on top of these giants' shoulders, and give you DOOM via Checkboxes. 27 | 28 | ## How 29 | 30 | DOOM runs via WebAssembly in a hidden ``. I use [HTMLCanvasElement.captureStream()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream) to turn this into a MediaStream. A `` element displays this MediaStream and is then consumed by [renderVideo](https://www.bryanbraun.com/checkboxland/#rendervideo) from Checkboxland. 31 | 32 | Optionally, the `` element can be hidden as well. However, test users were unable to exit the main menu without the aid of the original hi-res DOOM. 33 | 34 | Our screen is a 160 by 100 grid of native checkboxes. Higher resolutions work but FPS drops off dramatically. 35 | 36 | ```js 37 | const cbl = new Checkboxland({ 38 | dimensions: "160x100", 39 | selector: "#checkboxes", 40 | }); 41 | ``` 42 | 43 | The cursed CSS property [zoom](https://developer.mozilla.org/en-US/docs/Web/CSS/zoom) is used to shrink the checkboxes down. `transform: scale(x)` resulted in worse performance, and worse visuals. Unfortunately, this means that Firefox users need to manually zoom out. 44 | 45 | > Non-standard: This feature is non-standard and is not on a standards track. Do not use it on production sites facing the Web: it will not work for every user. 46 | 47 | Key events are forwarded to the hidden `` to avoid focus issues. 48 | 49 | ```js 50 | const forwardKey = (e, type) => { 51 | const ev = new KeyboardEvent(type, { 52 | key: e.key, 53 | keyCode: e.keyCode, 54 | }); 55 | canvas.dispatchEvent(ev); 56 | }; 57 | 58 | document.body.addEventListener("keydown", function (e) { 59 | forwardKey(e, "keydown"); 60 | }); 61 | 62 | document.body.addEventListener("keyup", function (e) { 63 | forwardKey(e, "keyup"); 64 | }); 65 | ``` 66 | 67 | While the `.wasm` is downloaded and processed, the grid displays a message via [print](https://www.bryanbraun.com/checkboxland/#print). 68 | 69 |  70 | 71 | Afterwards, the user is instructed to click anywhere (a user action is required so that the `` can be programmatically played) and the game begins! 72 | 73 | ## Development 74 | 75 | ```bash 76 | python dev.py 77 | ``` 78 | 79 | Edit files, refresh. 80 | -------------------------------------------------------------------------------- /dev.py: -------------------------------------------------------------------------------- 1 | import http.server 2 | 3 | PORT = 8001 4 | 5 | HandlerClass = http.server.SimpleHTTPRequestHandler 6 | HandlerClass.extensions_map[".js"] = "text/javascript" 7 | 8 | print(f"http://localhost:{PORT}") 9 | http.server.test(HandlerClass, port=PORT) 10 | -------------------------------------------------------------------------------- /doom.js: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/diekmann/wasm-fizzbuzz 2 | // Lots of unnecessary code in here 3 | 4 | "use strict"; 5 | var memory = new WebAssembly.Memory({ initial: 108 }); 6 | 7 | /*stdout and stderr goes here*/ 8 | const output = document.getElementById("output"); 9 | 10 | function readWasmString(offset, length) { 11 | const bytes = new Uint8Array(memory.buffer, offset, length); 12 | return new TextDecoder("utf8").decode(bytes); 13 | } 14 | 15 | function consoleLogString(offset, length) { 16 | const string = readWasmString(offset, length); 17 | console.log('"' + string + '"'); 18 | } 19 | 20 | function appendOutput(style) { 21 | return function (offset, length) { 22 | const lines = readWasmString(offset, length).split("\n"); 23 | for (var i = 0; i < lines.length; ++i) { 24 | if (lines[i].length == 0) { 25 | continue; 26 | } 27 | var t = document.createElement("span"); 28 | t.classList.add(style); 29 | t.appendChild(document.createTextNode(lines[i])); 30 | output.appendChild(t); 31 | output.appendChild(document.createElement("br")); 32 | t.scrollIntoView({ 33 | behavior: "smooth", 34 | block: "end", 35 | inline: "nearest", 36 | }); /*smooth scrolling is experimental according to MDN*/ 37 | } 38 | }; 39 | } 40 | 41 | /*stats about how often doom polls the time*/ 42 | const getmsps_stats = document.getElementById("getmsps_stats"); 43 | const getms_stats = document.getElementById("getms_stats"); 44 | var getms_calls_total = 0; 45 | var getms_calls = 0; // in current second 46 | window.setInterval(function () { 47 | getms_calls_total += getms_calls; 48 | getmsps_stats.innerText = getms_calls / 1000 + "k"; 49 | getms_stats.innerText = getms_calls_total; 50 | getms_calls = 0; 51 | }, 1000); 52 | 53 | function getMilliseconds() { 54 | ++getms_calls; 55 | return performance.now(); 56 | } 57 | 58 | /*doom is rendered here*/ 59 | const canvas = document.getElementById("screen"); 60 | const doom_screen_width = 320 * 2; 61 | const doom_screen_height = 200 * 2; 62 | 63 | /*printing stats*/ 64 | const fps_stats = document.getElementById("fps_stats"); 65 | const drawframes_stats = document.getElementById("drawframes_stats"); 66 | var number_of_draws_total = 0; 67 | var number_of_draws = 0; // in current second 68 | window.setInterval(function () { 69 | number_of_draws_total += number_of_draws; 70 | drawframes_stats.innerText = number_of_draws_total; 71 | fps_stats.innerText = number_of_draws; 72 | number_of_draws = 0; 73 | }, 1000); 74 | 75 | function drawCanvas(ptr) { 76 | var doom_screen = new Uint8ClampedArray( 77 | memory.buffer, 78 | ptr, 79 | doom_screen_width * doom_screen_height * 4 80 | ); 81 | var render_screen = new ImageData( 82 | doom_screen, 83 | doom_screen_width, 84 | doom_screen_height 85 | ); 86 | var ctx = canvas.getContext("2d"); 87 | 88 | ctx.putImageData(render_screen, 0, 0); 89 | 90 | ++number_of_draws; 91 | } 92 | 93 | /*These functions will be available in WebAssembly. We also share the memory to share larger amounts of data with javascript, e.g. strings of the video output.*/ 94 | var importObject = { 95 | js: { 96 | js_console_log: appendOutput("log"), 97 | js_stdout: appendOutput("stdout"), 98 | js_stderr: appendOutput("stderr"), 99 | js_milliseconds_since_start: getMilliseconds, 100 | js_draw_screen: drawCanvas, 101 | }, 102 | env: { 103 | memory: memory, 104 | }, 105 | }; 106 | 107 | WebAssembly.instantiateStreaming(fetch("doom.wasm"), importObject).then( 108 | (obj) => { 109 | /*Initialize Doom*/ 110 | obj.instance.exports.main(); 111 | 112 | /*input handling*/ 113 | let doomKeyCode = function (keyCode) { 114 | // Doom seems to use mostly the same keycodes, except for the following (maybe I'm missing a few.) 115 | switch (keyCode) { 116 | case 8: 117 | return 127; // KEY_BACKSPACE 118 | case 17: 119 | return 0x80 + 0x1d; // KEY_RCTRL 120 | case 18: 121 | return 0x80 + 0x38; // KEY_RALT 122 | case 37: 123 | return 0xac; // KEY_LEFTARROW 124 | case 38: 125 | return 0xad; // KEY_UPARROW 126 | case 39: 127 | return 0xae; // KEY_RIGHTARROW 128 | case 40: 129 | return 0xaf; // KEY_DOWNARROW 130 | default: 131 | if (keyCode >= 65 /*A*/ && keyCode <= 90 /*Z*/) { 132 | return keyCode + 32; // ASCII to lower case 133 | } 134 | if (keyCode >= 112 /*F1*/ && keyCode <= 123 /*F12*/) { 135 | return keyCode + 75; // KEY_F1 136 | } 137 | return keyCode; 138 | } 139 | }; 140 | let keyDown = function (keyCode) { 141 | obj.instance.exports.add_browser_event(0 /*KeyDown*/, keyCode); 142 | }; 143 | let keyUp = function (keyCode) { 144 | obj.instance.exports.add_browser_event(1 /*KeyUp*/, keyCode); 145 | }; 146 | 147 | /*keyboard input*/ 148 | canvas.addEventListener( 149 | "keydown", 150 | function (event) { 151 | keyDown(doomKeyCode(event.keyCode)); 152 | event.preventDefault(); 153 | }, 154 | false 155 | ); 156 | canvas.addEventListener( 157 | "keyup", 158 | function (event) { 159 | keyUp(doomKeyCode(event.keyCode)); 160 | event.preventDefault(); 161 | }, 162 | false 163 | ); 164 | 165 | /*mobile touch input*/ 166 | [ 167 | ["enterButton", 13], 168 | ["leftButton", 0xac], 169 | ["rightButton", 0xae], 170 | ["upButton", 0xad], 171 | ["downButton", 0xaf], 172 | ["ctrlButton", 0x80 + 0x1d], 173 | ["spaceButton", 32], 174 | ["altButton", 0x80 + 0x38], 175 | ].forEach(([elementID, keyCode]) => { 176 | // console.log(elementID + " for " + keyCode); 177 | var button = document.getElementById(elementID); 178 | //button.addEventListener("click", () => {keyDown(keyCode); keyUp(keyCode)} ); 179 | button.addEventListener("touchstart", () => keyDown(keyCode)); 180 | button.addEventListener("touchend", () => keyUp(keyCode)); 181 | button.addEventListener("touchcancel", () => keyUp(keyCode)); 182 | }); 183 | 184 | /*hint that the canvas should have focus to capute keyboard events*/ 185 | const focushint = document.getElementById("focushint"); 186 | const printFocusInHint = function (e) { 187 | focushint.innerText = 188 | "Keyboard events will be captured as long as the the DOOM canvas has focus."; 189 | focushint.style.fontWeight = "normal"; 190 | }; 191 | canvas.addEventListener("focusin", printFocusInHint, false); 192 | 193 | canvas.addEventListener( 194 | "focusout", 195 | function (e) { 196 | focushint.innerText = 197 | "Click on the canvas to capute input and start playing."; 198 | focushint.style.fontWeight = "bold"; 199 | }, 200 | false 201 | ); 202 | 203 | canvas.focus(); 204 | printFocusInHint(); 205 | 206 | /*printing stats*/ 207 | const animationfps_stats = document.getElementById("animationfps_stats"); 208 | var number_of_animation_frames = 0; // in current second 209 | window.setInterval(function () { 210 | animationfps_stats.innerText = number_of_animation_frames; 211 | number_of_animation_frames = 0; 212 | }, 1000); 213 | 214 | /*Main game loop*/ 215 | function step(timestamp) { 216 | ++number_of_animation_frames; 217 | obj.instance.exports.doom_loop_step(); 218 | window.requestAnimationFrame(step); 219 | } 220 | window.requestAnimationFrame(step); 221 | 222 | // @healeycodes - these are the only lines I've really changed in this file 223 | window.cbl.clearData(); 224 | window.doomLoaded = true; 225 | window.cbl.print("Ready! Click anywhere to play."); 226 | } 227 | ); 228 | -------------------------------------------------------------------------------- /doom.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/doom-checkboxes/1d282fc696d45378423f7244989fb743b2c4c485/doom.wasm -------------------------------------------------------------------------------- /glue.js: -------------------------------------------------------------------------------- 1 | import { Checkboxland } from "https://unpkg.com/checkboxland?module"; 2 | window.Checkboxland = Checkboxland; 3 | 4 | const canvas = document.querySelector("canvas"); 5 | const video = document.querySelector("#doom-video"); 6 | const cbl = new Checkboxland({ 7 | dimensions: "160x100", 8 | selector: "#checkboxes", 9 | }); 10 | window.cbl = cbl; 11 | cbl.print("DOOM WebAssembly loading.."); 12 | 13 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1572422 14 | // Looks like canvas.captureStream() doesn't work unless 15 | // you've already called canvas.getContext() 16 | canvas.getContext("2d"); 17 | setTimeout(() => (video.srcObject = canvas.captureStream()), 0); 18 | 19 | document.body.onmousedown = () => { 20 | if (window.doomLoaded !== true) { 21 | return; 22 | } 23 | cbl.renderVideo(video, { threshold: 20 }); 24 | video.play(); 25 | }; 26 | 27 | const forwardKey = (e, type) => { 28 | const ev = new KeyboardEvent(type, { 29 | key: e.key, 30 | keyCode: e.keyCode, 31 | }); 32 | canvas.dispatchEvent(ev); 33 | }; 34 | 35 | document.body.addEventListener("keydown", function(e) { 36 | forwardKey(e, "keydown"); 37 | }); 38 | 39 | document.body.addEventListener("keyup", function(e) { 40 | forwardKey(e, "keyup"); 41 | }); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | DOOM via Checkboxes 15 | 16 | 18 | 40 | 41 | 42 | 43 | 44 | DOOM via checkboxes. Desktop Chrome/Edge only. By @healeycodes. Read the blog post: DOOM Rendered via Checkboxes. 45 | 46 | 47 | 48 | 49 | This is where the DooM screen should render. 50 | 51 | 52 | 53 | gettmilliseconds calls per second: 0 Total getmilliseconds calls: 0 DooM FPS: 0 (target: 35FPS) Total Frames drawn: 0 Browser Animation FPS: 0 (target: around 60FPS, depending on browser) 55 | 56 | 57 | Use ⏎ to start the game 58 | arrow keys ← 59 | ↑ 60 | ↓ 61 | → to move 62 | ctrl to shoot 63 | 64 | 65 | spacebar to open gates 66 | 67 | alt and arrow keys to strafe (if your browser does not handle these keys otherwise). 68 | 69 | 70 | 71 | 72 | 73 | Credits: DOOM via WebAssembly from Scratch Article, Checkbox library via Checkboxland. 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/doom-checkboxes/1d282fc696d45378423f7244989fb743b2c4c485/loading.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/doom-checkboxes/1d282fc696d45378423f7244989fb743b2c4c485/preview.png --------------------------------------------------------------------------------
44 | DOOM via checkboxes. Desktop Chrome/Edge only. By @healeycodes. Read the blog post: DOOM Rendered via Checkboxes. 45 |
Credits: DOOM via WebAssembly from Scratch Article, Checkbox library via Checkboxland. 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/doom-checkboxes/1d282fc696d45378423f7244989fb743b2c4c485/loading.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/doom-checkboxes/1d282fc696d45378423f7244989fb743b2c4c485/preview.png --------------------------------------------------------------------------------