A personal site that boots inside a browser tab. The whole thing is written in
freestanding C, compiled to WebAssembly, and painted into a single <canvas>
one pixel at a time.
There is no HTML for the UI. No React. No CSS layout inside. Every window, titlebar, icon, and character of text is drawn by C code into a software framebuffer.
- Kernel + apps: ~13,000 lines of freestanding C
- Binary: ~59 KB gzipped
os.wasm(-Oz -flto+wasm-opt) - JS shim: ~430 lines — loads the module, forwards input (keyboard + mouse + touch), blits the framebuffer once per frame, and provides imports (clock, fetch, image decode, keyed localStorage, Web Audio, clipboard)
- Runtime deps: none (no libc, no runtime)
Everything below — desktop, dock, menu bar, windows, text, the game board — is
drawn by C into a software framebuffer. No DOM, no <div>s.
![]() |
![]() |
| Desktop — icons, dock, menu-bar clock | Terminal — neofetch in the built-in shell |
![]() |
![]() |
| 2048 — a windowed app over the terminal | Same OS, bare metal — booted from os.elf in QEMU (i686) |
The last shot is the identical OS core booting on bare-metal x86 (no browser, no
JS) — the platform ABI (src/include/host.h) is the only thing that differs.
Render a still headlessly with the snippet under "Snapshots" below, or drive it
in a real browser for the interactive shell.
npm run dev # builds os.wasm, assembles dist/, serves it
open http://localhost:8000
dist/ is the only servable tree (the web sources live in src/platform/web/,
and main.js loads os.wasm as a sibling). make serve does the same thing.
Then:
- Double-click a desktop icon, or click one in the dock, to launch an app.
- Spotlight: press
⌘K/Ctrl+K(or click the menu-bar magnifier) to search apps + documents; type to filter, ↑/↓ to move, Enter to launch, Esc to close. - Menus: click the
MarkCodesOSbrand for the Apple menu (About / running windows / Restart / Shut Down). Right-click the desktop for a context menu, or right-click a dock icon for Show / Quit / Open. - Windows: drag a titlebar; grab the bottom-right corner to resize. Double-click a titlebar to zoom. Drag a window to a screen edge to snap (top = maximize, left/right = half). The three dots are close (red) / minimise (yellow) / zoom (green); minimise flies to the dock.
- Change Desktop (right-click menu) cycles the wallpaper tint. Your wallpaper + open windows are saved and restored across reloads.
- Type
helpin Terminal for the command list. Tryls,cd apps,cat about.txt,open snake,cd ../games,open 2048. - Games: Snake (arrow keys/WASD, Enter to start, P to pause), 2048, Minesweeper (left-click reveal, right-click flag), Tetris, Pong, Breakout, Tic-tac-toe. Browse them in the Files app under
games/or search via Spotlight. - Apps: Settings (wallpaper + sound toggle), Calculator, Pixel-paint (64×64 canvas, auto-saves, copy to clipboard).
new-website/
├── Makefile # wasm build (os.wasm)
├── Makefile.x86 # bare-metal i686 build (os.elf, boots in QEMU)
├── sources.mk # shared core+apps source list (both builds include it)
├── os.wasm # wasm build output (~59 KB gzipped)
├── posts.json # blog posts (fetched at runtime)
├── videos.json # video list (fetched at runtime)
├── toolchain/wasi-sdk/ # local WASI SDK 33 (clang 22 + wasm-ld)
└── src/
├── include/
│ ├── os.h # shared types, palette, RGB() macro, api
│ ├── host.h # the platform ABI contract (host_* fns) — one source of truth
│ └── font_data.h # 6x8 bitmap font (printable ASCII + icon glyphs)
├── platform/ # the two ports; each supplies the host_* ABI
│ ├── web/ # web port: index.html + main.js shim + style.css
│ └── x86/ # bare-metal port: boot.S, kernel.c, host.c, linker.ld
├── main.c # wasm exports + boot + power (shutdown/restart) + app registry
├── runtime.c # memcpy/memset/strlen/... + xorshift RNG + sin/cos LUT
├── memory.c # 16 MiB bump arena
├── framebuffer.c # put_pixel / rect / hline / circle / rounded / clip stack
├── font.c # text_draw, text_draw_big
├── theme.c # panels, titlebars, buttons, desktop bg + wallpaper presets
├── input.c # global mouse state
├── wm.c # window manager (drag / resize / snap / zoom / animations)
├── vfs.c # in-memory filesystem tree (apps/ + games/ + logs/)
├── shell.c # desktop icons + menu bar + dock + menus + wall-clock
├── spotlight.c # ⌘K search overlay (filter + launch apps/docs)
├── session.c # persist wallpaper + open windows to localStorage
├── store.c # keyed localStorage wrapper (notes, high scores, prefs)
├── snd.c # Web Audio tone/noise effects (gated by g_sound_on)
├── clip.c # clipboard write via the host ABI
├── net.c # async fetch via the host ABI
├── img.c # image decode (host) + scale/blit
├── json.c # JSON parser (blog posts + videos)
└── apps/
├── text_body.c # shared word-wrapped reader (about/projects/contact/resume/readme)
├── terminal.c # scrollback + cursor + shell parser
├── files.c # icon grid file explorer
├── clock.c # analog + digital (real wall-clock)
├── notes.c # editable sticky note (auto-saved)
├── minigame.c # snake (hi-score + pause)
├── blog.c # blog index + reader (posts.json)
├── videos.c # talks/videos with thumbnails (videos.json)
├── settings.c # wallpaper presets + sound toggle
├── calc.c # integer calculator
├── paint.c # 64x64 pixel editor (auto-save + clipboard export)
├── g2048.c # 2048
├── mines.c # minesweeper
├── tetris.c # tetris
├── pong.c # pong
├── breakout.c # breakout
└── ttt.c # tic-tac-toe
The OS core is platform-agnostic. Everything it needs from the outside world it
gets through a small host_* ABI declared in host.h. Each port supplies it:
- web (
src/platform/web/main.js): the functions are WASM imports inenv. - x86 (
src/platform/x86/host.c): ordinary C functions of the same name, so the core links unchanged on bare metal.
The entire JS surface is:
- Allocate a
WebAssembly.Memory({ initial: 512, maximum: 512 })= 32 MiB. - Instantiate
os.wasmwith that memory imported intoenv.memory. - Call
init(width, height)once. Callon_resize(w, h)on window resize. - Read the exported
fb_ptr()and wrap it as anImageDatabacked directly by WASM memory (zero-copy per frame). - Each
requestAnimationFrame: callframe(delta_ms), thenctx.putImageData(imageData, 0, 0). - Forward
mousemove/down/up/wheel→on_mouse(x, y, buttons, wheel, kind). - Forward
keydown/keyup→on_key(keycode, ch, mods, down).
No app logic, no drawing, no state. main.js also implements the host_* ABI
(src/include/host.h) as WASM imports (all resolved via -Wl,--allow-undefined,
so the core just includes host.h):
host_wall() # packed local wall-clock (see menu-bar clock)
host_fetch(urlPtr,len,reqId) # async fetch -> on_fetch_done()
host_open_url(urlPtr,len) # open external link in a new tab
host_load_image(urlPtr,len,reqId,w,h) # decode image -> on_image_done()
host_load(keyPtr,keyLen,ptr,cap) -> len # read localStorage[key] into WASM
host_save(keyPtr,keyLen,ptr,len) # write localStorage[key]
host_del_key(keyPtr,keyLen) # remove localStorage[key]
host_tone(freq,durMs,wave,vol) # Web Audio tone (wave: 0-3, vol: 0-255)
host_noise(durMs,vol) # Web Audio white-noise burst
host_clip_write(ptr,len) # navigator.clipboard.writeText
host_clip_request_read() # async clipboard read -> on_clip_paste()
Because these are hard imports, any headless host (see "Snapshots") must
provide stubs for at least host_wall, host_load, and host_save — they are
called every frame / at boot, and a missing import is a LinkError.
init(w, h)
on_resize(w, h)
frame(dt_ms)
on_mouse(x, y, buttons, wheel, kind) # kind: 0 move 1 down 2 up 3 wheel
on_key(keycode, ch, mods, down) # mods bits: 1 shift 2 ctrl 4 alt 8 meta
fb_ptr() -> u32 # pointer to RGBA framebuffer
u32* array of W*H pixels. Each u32 is little-endian [R, G, B, A] byte
order — which is what ImageData reads directly. Use the RGB(r,g,b) macro
in os.h to build colors so the byte order stays correct.
Max resolution is 1920 × 1200 (s_fb_storage in framebuffer.c); JS caps
canvas.width/height accordingly, then CSS scales it up.
- Windows are
Windowstructs in a fixed pool of 12. s_order[]holds back-to-front slot order; the last entry is focused.- Drawing walks
s_order[]back to front, sets a clip rect to each window's content region, and calls the app'son_draw(w). - Input hit-tests top-down; the topmost hit gets focus + the event. Titlebar buttons and resize grip are checked first.
- Apps register a static
const Appwithon_init/on_draw/on_event/on_tick.
Transitions. Each Window carries an anim (AnimKind: open / close /
minimize / restore) and anim_ms. While animating, wm_draw paints only the
chrome, scaled around a moving centre (anim_rect) — content is suppressed and
the window is non-interactive. Minimise/restore fly to the app's real dock icon
(shell_dock_icon_pos), else the dock centre. wm_close is deferred until the
close animation finishes; wm_open_rect restores a saved window with no
animation (used by session restore).
Zoom & snapping. Double-clicking a titlebar or the green light toggles
toggle_zoom (fills the work area, remembers the prior rect). Dragging a
titlebar into a screen edge shows a translucent snap preview and, on release,
tiles the window: top = maximize, left/right = half. Dragging a snapped window
restores its pre-snap size under the cursor. Pre-zoom/pre-snap rects live in
s_saved[], keyed by slot.
The shell owns everything outside app windows: the menu bar, dock, desktop icons, menus, notifications, and the wall-clock.
Input routing (in main.c) is layered — overlays capture input by
returning early, before the window manager sees it:
on_mouse DOWN: spotlight → shell_event_pre (menus / menu-bar / dock right-click)
→ wm_event (windows) → shell_event (icons / dock)
on_key: ⌘K toggles Spotlight → Spotlight (if open) → Esc closes a menu
→ wm_event (focused window)
shell_draw paints back-to-front: desktop bg → icons → wm_draw (+ snap
preview) → menu bar → dock → menus → Spotlight.
Menus. One reusable dropdown (MenuItem[] + draw_menu + menu_run)
backs three menus: the Apple menu (brand mark → About / live running-window
switcher / Restart / Shut Down), the desktop context menu (right-click →
New Note / Open Terminal / Change Desktop / Clean Up / About), and the dock
menu (right-click an icon → Show + Quit if running, else Open). Menu actions
are either an ACT_* command or a launch/quit-by-app_id.
Spotlight (spotlight.c) is a modal search overlay: ⌘K / Ctrl+K or the
menu-bar magnifier. It substring-matches APP_THEMES labels/ids, dims the
background, and launches via shell_launch. Note: ⌘Space is reserved by the
OS/browser, so K is the shortcut; main.js preventDefaults it.
Menu-bar clock shows local time + date. The host packs it into one u32 via
the host_wall ABI: secs-of-day (17b) | weekday 0=Sun (3b) | day 1..31 (5b) | month 0..11 (4b); C unpacks and formats with its own weekday/month tables. (On
web this reads new Date(); on bare metal, the CMOS RTC.)
Wallpaper is a runtime tint (g_desk_top/g_desk_bot in theme.c) with 5
presets; "Change Desktop" cycles them (theme_cycle_desktop).
The Apple menu's Shut Down / Restart run a fade-to-black
(dim_screen multiplies the framebuffer down over ~480 ms), then either a dark
"safe to close this tab" screen (click to power back on) or a reboot() that
resets the WM/shell and replays the boot animation.
Wallpaper + open windows are serialized to a compact string and stored in
localStorage["mc_session"] via the host ABI (host_save / host_load):
1|d<deskIndex>|w<id>,<x>,<y>,<w>,<h>,<min>|w...
State changes call session_mark_dirty(); frame flushes at most every
~400 ms (and force-flushes on shutdown/restart). On boot, session_restore()
rebuilds the desktop; if there's no saved session it opens README + Terminal.
Restored windows use wm_open_rect so they appear instantly without the open
animation.
Static tree — every "file" is a pointer to a C string literal in vfs.c:
/
├── readme.txt about.txt projects.txt contact.txt
├── resume.txt
├── apps/
│ ├── terminal files clock notes snake
│ ├── about blog videos resume
│ ├── settings calc paint
├── games/
│ ├── 2048 mines tetris pong breakout ttt
└── logs/
└── boot.log
The Terminal and Files apps share this tree. open <app> and double-clicking
a VFS_APP node both go through shell_launch().
Warm-amber CRT accent (#ffb84a) on muted teal desktop (#4a6a6a →
#2a4a4a gradient), off-white chrome, near-black ink.
Window chrome is mac-inspired: light unified titlebars with a subtle
vertical gradient, three round traffic lights on the left in red / yellow / green order (vivid on focused windows, greyed out on
unfocused), and rounded outer corners. Corner rounding in a software
framebuffer is done by saving the underlying pixels before drawing chrome
and writing them back afterwards through a per-corner mask — see
corner_save/corner_restore in wm.c (~30 lines).
Dock is a floating rounded island centered near the bottom, not a full-width bar. Each app has its own colored icon square with a large glyph; hovering an icon magnifies it above the panel and shows a tooltip pill with a downward notch. A small dark dot appears under each running app.
Colors are defined once in os.h using RGB(r, g, b) and referenced by
name — no hex literals scattered in app code.
Toolchain: WASI SDK 33 (bundled under toolchain/wasi-sdk/). Only clang
and wasm-ld are used — no libc, no WASI imports.
clang --target=wasm32 -std=gnu11 -Oz -flto \
-nostdlib -ffreestanding -fno-builtin \
-fvisibility=hidden -Isrc/include \
-Wl,--no-entry -Wl,--import-memory \
-Wl,--initial-memory=33554432 -Wl,--max-memory=33554432 \
-Wl,--stack-first -Wl,-z,stack-size=1048576 \
-Wl,--export=init -Wl,--export=frame \
-Wl,--export=on_mouse -Wl,--export=on_key \
-Wl,--export=on_resize -Wl,--export=fb_ptr \
src/*.c src/apps/*.c -o os.wasm
Adding a new app:
- Write
src/apps/foo.cwithfoo_init/foo_draw/foo_event/foo_tickand aconst App APP_FOO = { "foo", "foo", w, h, sizeof(FooState), ... }; - Add
extern const App APP_FOO;toos.h. - Register it in the
APP_THEMES[]table inmain.c— the single source of truth for display metadata:{"foo", "foo", ICON_STAR, RGB(bg), RGB(fg), &APP_FOO, is_doc}.app_by_id()/app_theme()look it up here; Spotlight and Files pick it up automatically. - Add the source path to
CORE_SRCSinsources.mk(both builds pick it up). - Placement: the first
NUM_ICONS(currently 14)APP_THEMESentries appear on the desktop grid (shell.c); theDOCK_ORDER[]array inshell.ccontrols which apps appear in the dock and in what order. Apps placed after thereadmeentry are excluded from the desktop grid but still reachable via Spotlight and the Files app (this is how the 6 games are surfaced).
The About / Projects / Contact / README text lives as C string literals at
the top of src/vfs.c. Edit those strings and rebuild.
The WASM module can be driven from Node for CI screenshots or smoke tests:
import fs from "node:fs/promises";
import { PNG } from "pngjs";
const bytes = await fs.readFile("os.wasm");
const memory = new WebAssembly.Memory({ initial: 512, maximum: 512 });
// Stub every import the module declares, or instantiate throws LinkError.
// host_wall (every frame) + host_load (boot) + host_save must at least be present.
const env = {
memory,
host_wall: () => 0, // 00:00, Sun, day 0, Jan — fine for stills
host_load: () => 0, // no saved session (keyed: keyPtr,keyLen,ptr,cap)
host_save: () => {}, // keyed: keyPtr,keyLen,ptr,len
host_del_key: () => {},
host_fetch: () => {}, host_open_url: () => {}, host_load_image: () => {},
host_tone: () => {}, host_noise: () => {}, host_clip_write: () => {},
host_clip_request_read: () => {},
};
const { instance } = await WebAssembly.instantiate(bytes, { env });
const w = instance.exports;
const [W, H] = [1200, 800];
w.init(W, H);
for (let i = 0; i < 150; i++) w.frame(16); // past boot animation
const buf = new Uint8Array(memory.buffer, w.fb_ptr(), W * H * 4);
const png = new PNG({ width: W, height: H });
png.data = Buffer.from(buf);
await fs.writeFile("out.png", PNG.sync.write(png));Personal project. Font in src/include/font_data.h is a hand-drawn 6×8
bitmap, free to reuse.



