Chapter 4: Serving the game: a tiny web server in Odin
This chapter: build a static-file HTTP server in ~100 lines of Odin, compiled to a native binary, so we can load the WASM over HTTP with the right MIME types. Then wrap it in build/run scripts.
Where we left off: the Chapter 3 spike compiles but can't be opened from disk.
4.1 Why we can't just open the file
instantiateStreaming(fetch("โฆโ.wasm")) fetches the .wasm over HTTP. Under a file:// URL the browser blocks that fetch with a CORS error, so the page never loads. We need an HTTP server. And one detail is non-negotiable:
Gotcha. The server must send
.wasmfiles withContent-Type: application/wasm. Streaming instantiation validates the MIME type and refuses anything else. A server that serves your wasm asapplication/octet-streamwill fail with a confusing error even though the bytes are correct.
We could grab an off-the-shelf server, but writing our own is a dozen concepts in one small program, and it's our second Odin build target: native, not WASM.
4.2 Listening for connections
core:net gives us TCP. Bind a socket to a port and accept connections in a loop:
// server/serve.odin
package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strings"
ROOT :: "dist" // directory served, relative to where you run the binary
PORT :: 9000
main :: proc() {
endpoint := net.Endpoint{
address = net.IP4_Address{0, 0, 0, 0}, // all interfaces
port = PORT,
}
listen, lerr := net.listen_tcp(endpoint)
if lerr != nil {
fmt.eprintfln("could not listen on port %d: %v", PORT, lerr)
os.exit(1)
}
defer net.close(listen)
fmt.printfln("Serving ./%s at http://localhost:%d (Ctrl-C to stop)", ROOT, PORT)
for {
client, _, aerr := net.accept_tcp(listen)
if aerr != nil {
fmt.eprintfln("accept failed: %v", aerr)
continue
}
handle_connection(client)
net.close(client)
}
}
This is deliberately a development server: single-threaded, one request per connection, no keep-alive. That's all a local dev loop needs, and it keeps the code readable. A production server would add concurrency, timeouts, caching headers, and range requests: out of scope here.
4.3 Reading the request and finding the file
An HTTP request starts with a line like GET /batch.js HTTP/1.1. We only need the path (the middle field). We read the request into a stack buffer, pull out the path, map / to index.html, refuse anything with .., and prepend the served root:
handle_connection :: proc(client: net.TCP_Socket) {
buf: [4096]u8
n, rerr := net.recv_tcp(client, buf[:])
if rerr != nil || n == 0 {
return
}
path := request_path(string(buf[:n]))
if path == "" {
send_status(client, 400, "Bad Request")
return
}
if path == "/" {
path = "/index.html"
}
if strings.contains(path, "..") {
send_status(client, 403, "Forbidden") // never let a path escape ROOT
return
}
full := strings.concatenate({ROOT, path})
defer delete(full)
data, derr := os.read_entire_file_from_path(full, context.allocator)
if derr != nil {
send_status(client, 404, "Not Found")
return
}
defer delete(data)
send_file(client, content_type(path), data)
}
request_path :: proc(req: string) -> string {
line := req
if i := strings.index_any(req, "\r\n"); i >= 0 {
line = req[:i]
}
parts := strings.fields(line) // ["GET", "/batch.js", "HTTP/1.1"]
defer delete(parts)
if len(parts) < 2 {
return ""
}
return parts[1]
}
Why the
..check? Without it, a request for/../serve.odinwould hand out files outside the served directory. Refusing any path containing..is a blunt but effective guard for a dev server.
4.4 MIME types
One tiny table, and the application/wasm line is the one that earns its keep:
content_type :: proc(path: string) -> string {
switch {
case strings.has_suffix(path, ".html"): return "text/html; charset=utf-8"
case strings.has_suffix(path, ".js"): return "text/javascript; charset=utf-8"
case strings.has_suffix(path, ".css"): return "text/css; charset=utf-8"
case strings.has_suffix(path, ".wasm"): return "application/wasm"
case strings.has_suffix(path, ".json"): return "application/json; charset=utf-8"
case: return "application/octet-stream"
}
}
4.5 Writing the response
A minimal HTTP/1.1 response is a status line, a couple of headers, a blank line, then the body:
send_file :: proc(client: net.TCP_Socket, ctype: string, body: []u8) {
header := fmt.tprintf(
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
ctype, len(body),
)
net.send_tcp(client, transmute([]u8)header)
net.send_tcp(client, body)
}
send_status :: proc(client: net.TCP_Socket, code: int, text: string) {
body := fmt.tprintf("%d %s\n", code, text)
resp := fmt.tprintf(
"HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
code, text, len(body), body,
)
net.send_tcp(client, transmute([]u8)resp)
}
fmt.tprintf formats into temporary storage; transmute([]u8)string reinterprets the string's bytes as a byte slice to send. Connection: close tells the browser we hang up after one response, which matches our one-request-per-connection loop.
4.6 Building and running it
This is a native build: no -target, no -no-entry-point; it has a real main. Note the output name differs from the package directory (server/) so it doesn't collide:
odin build server -out:serve
./serve
# => Serving ./dist at http://localhost:9000 (Ctrl-C to stop)
4.7 The build and run scripts
Two small shell scripts tie it together. build.sh compiles the game and assembles dist/ (we'll fill in the game in later chapters; the shape is already right):
#!/usr/bin/env bash
# build.sh: compile the game to WASM and assemble dist/ (plain, unminified).
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
echo "==> Compiling Odin -> WebAssembly ..."
odin build src -out:dist/destroids.wasm -target:js_wasm32 -no-entry-point -o:size
echo "==> Copying assets ..."
cp web/batch.js dist/batch.js
cp index.html dist/index.html
ls -lh dist/
run.sh builds everything and starts the server:
#!/usr/bin/env bash
# run.sh: build the game and server, then serve dist/ on :9000.
set -euo pipefail
cd "$(dirname "$0")"
bash build.sh
echo "==> Building the dev server (native) ..."
odin build server -out:serve -o:size
echo "=== Starting server ==="
./serve
These are a starting point: change the flags, names, or layout freely. On Windows, the same two odin build lines work; wrap them in a .bat/.ps1 and run serve.exe.
Make them executable and you have your dev loop:
chmod +x build.sh run.sh
./run.sh # then open http://localhost:9000
Point the Chapter 3 spike's HTML at this server (drop it in dist/) and the blue rectangle finally appears, the round trip from Chapter 3, now actually on screen.
Checkpoint
You have a native Odin HTTP server that serves dist/ on port 9000 with correct MIME types (including application/wasm), refuses path-escape attempts, and returns 404s, plus build.sh and run.sh to drive the whole loop.
Exercises
- Add a startup line that logs each request's method and path as it's served.
- Add a
.svgโimage/svg+xmlrow tocontent_type. - Request a missing file and confirm you get a 404; request
/and confirm you getindex.html.
Further reading
- Odin
core:net/core:os: https://pkg.odin-lang.org/ - MDN: MIME types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
- WebAssembly on the web (why
application/wasm): https://webassembly.org/