First milestone of the ftl-shape vertical slice. Establishes the Go + Ebitengine pipeline and a placeholder ship render that later milestones (crew-movement, systems-power, combat, polish) build on.
Purpose. Prove the Go + Ebitengine pipeline builds and runs on both desktop (macOS/Linux/Windows) and in-browser WASM, rendering a static placeholder player ship with 5 rooms in a ship-like layout. No interactivity. Foundation for every subsequent milestone.
In scope. Go module, Ebitengine main loop, virtual-resolution rendering, room data model, rectangle-based room rendering with labels, Makefile for native + WASM builds, minimal web/index.html shell, dev server via wasmserve, one-command "run in browser" target.
Out of scope. Input, crew, animation, enemy ship, sound, combat, menus, save system, disk-loaded assets, WASM size optimization.
Chosen approach.
Layout returns 640×360; all drawing in virtual pixels. Resizable window is fine — Ebitengine handles scaling.sourcecraft.dev/bigbes/ftl-shape). Files by concern:
main.go — entrypoint, Game struct, Update/Draw/Layout methods.ship.go — Ship, Room types, placeholder ship constructor.render.go — drawing helpers (rect fill, border, label).Ship{Rooms []Room} and Room{ID, Name, GridX, GridY, GridW, GridH, Role} in tile units. Render layer converts tile→virtual pixels at 16 px/tile.text/v2.Makefile targets.
run — native, go run ..build — native binary to bin/ftl-shape.build-wasm — produce web/main.wasm; copy wasm_exec.js from the installed Go toolchain ($(go env GOROOT)/lib/wasm/wasm_exec.js on Go ≥1.24).play-web — one-command "run in browser": start wasmserve (auto-rebuilds on source change), print http://localhost:8080, and auto-open the browser (open on macOS, xdg-open on Linux, print-only otherwise).serve — same as play-web without the auto-open step.web/index.html loads wasm_exec.js + main.wasm using the standard Go WASM snippet. .gitignore excludes bin/ and web/main.wasm.Tradeoffs that settled the decisions.
cmd/ + internal/: small surface area, minimum friction. Split when a file crosses ~300 lines or a real subsystem boundary emerges.Layout decision — negligible.Unknowns. WASM binary size for Ebitengine builds is typically 10–20 MB. Acceptable for dev; optimization is out of scope.
TDD: no — skeleton is a visual + build-pipeline proof. Ship/Room are trivial data structs with no logic to assert; success is "window opens, rectangles appear, WASM loads in browser." TDD applies at the crew-movement milestone where pathfinding has real behavior.
GOOS=js GOARCH=wasm and native targets; no platform-conditional imports (//go:build, GOOS-suffixed files) in this milestone.Room. Pixel conversion happens only at render time.ship.go does not import Ebitengine. Data types know nothing about rendering.Approach: two phases — first gets a native window rendering the placeholder ship, second adds the WASM build and the one-command browser target. Each phase is a self-contained runnable checkpoint.
Backwards-compat: greenfield project, nothing to break.
1.1 go.mod (modify)
github.com/hajimehoshi/ebiten/v2; run go mod tidy.1.2 ship.go (create)
main.type RoomRole int with constants RolePilot, RoleWeapons, RoleShields, RoleMedBay, RoleEngines.type Room struct { ID int; Name string; GridX, GridY, GridW, GridH int; Role RoomRole } — tile-unit coords only.type Ship struct { Rooms []Room }.func NewPlayerShip() Ship — hard-coded 5-room layout: Pilot front-left, Weapons mid-upper, Shields mid-center, MedBay mid-lower, Engines rear-right.1.3 render.go (create)
main. Constants TilePx = 16, VirtualW = 640, VirtualH = 360.func roleColor(role RoomRole) color.RGBA — role → tint mapping (mapping lives here, not in ship.go).func drawShip(screen *ebiten.Image, s Ship) — iterates rooms, delegates to drawRoom.func drawRoom(screen *ebiten.Image, r Room) — fills role-tinted rect, draws 1-px border, writes role name via ebitenutil.DebugPrintAt positioned inside the room.TilePx; no window-pixel math.1.4 main.go (create)
main. type Game struct { ship Ship }.func (g *Game) Update() error — returns nil.func (g *Game) Draw(screen *ebiten.Image) — fills dark background, calls drawShip(screen, g.ship).func (g *Game) Layout(int, int) (int, int) — returns VirtualW, VirtualH.func main() — ebiten.SetWindowSize(1280, 720), ebiten.SetWindowTitle("ftl-shape"), ebiten.RunGame(&Game{ship: NewPlayerShip()}), panic on error.Commit: scaffold game loop and placeholder ship render
2.1 Makefile (create)
run, build, build-wasm, serve, play-web, .PHONY line.build: go build -o bin/ftl-shape ..build-wasm: GOOS=js GOARCH=wasm go build -o web/main.wasm . then cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" web/wasm_exec.js (Go ≥1.24 path).serve: go run github.com/hajimehoshi/wasmserve@latest . — wasmserve rebuilds wasm on source change and serves on :8080.play-web: detect OS (uname), background-delay open/xdg-open http://localhost:8080, then invoke the same wasmserve command.2.2 web/index.html (create)
<script src="wasm_exec.js">, instantiate new Go(), WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(r => go.run(r.instance)). Canvas set to 1280×720.play-web critical path.2.3 .gitignore (create)
bin/, web/main.wasm, web/wasm_exec.js.Commit: add Makefile, WASM loader shell, play-web target
No automated tests (per TDD: no). Manual acceptance:
go run . → window opens showing 5 labeled, colored rooms on a dark background.make play-web → browser tab at :8080 shows identical render; make build produces a runnable bin/ftl-shape.wasm_exec.js at $GOROOT/lib/wasm/ — confirmed present for Go 1.26.2 (user's toolchain). Would fail loudly on Go <1.24; acceptable per design's fail-fast principle.Result: passed
Positive:
go build ./... → cleango vet ./... → cleanGOOS=js GOARCH=wasm go build → produces valid wasm (WebAssembly MVP, ~12 MB)make build → bin/ftl-shape Mach-O arm64make build-wasm → web/main.wasm + web/wasm_exec.js copied from $GOROOT/lib/wasm/make -n play-web → recipe expands to platform-detected open + wasmserve .bin/ftl-shape 2s in background, window alive, clean killweb/ via python3 -m http.server, loaded in Chrome, WASM instantiated and rendered 5 colored rooms with labels (Pilot, Weapons, Shields, MedBay, Engines) on dark background at 1280×720. Console shows only a benign 404 for favicon.ico.Invariants:
ship.go has no Ebitengine import (grep returned empty)//go:build directives, no platform-suffixed *_js.go / *_windows.go / *_linux.go / *_darwin.go filesTilePx references confined to render.go — pixel conversion happens only at render timegit status clean after make build + make build-wasm — .gitignore correctly excludes all artifactsOutcome: skeleton milestone delivered — native window and browser WASM both render the 5-room placeholder ship at c71f238.
Invariants:
TilePx references confined to render.go.//go:build, no platform-suffixed files; GOOS=js GOARCH=wasm go build passes.Room fields are pure ints; conversion in drawRoom only.ship.go free of Ebitengine imports — verified by grep.Future work:
text/v2 for labels while Plan + code use ebitenutil.DebugPrintAt; harmless doc drift, reviewer flagged sub-threshold. Justification: Plan was the contract and was approved with the DebugPrintAt choice; swap to text/v2 + real face is a polish-milestone concern, not skeleton.**Branch:** / **Worktree:** headers up front.