~bigbes/game-prototype-ftl

ref: 3b45141687e94d35b6a12c4a6f4897d9bd741f6f game-prototype-ftl/docs/tasks/skeleton.md -rw-r--r-- 10.6 KiB
3b451416 — Eugene Blikh docs(systems-power): record approved design 30 days ago

#skeleton

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.

#Design

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.

  • Rendering. Virtual resolution 640×360; window opens at 1280×720 (2× scale). Ebitengine's Layout returns 640×360; all drawing in virtual pixels. Resizable window is fine — Ebitengine handles scaling.
  • Project layout. Single package at module root (sourcecraft.dev/bigbes/ftl-shape). Files by concern:
    • main.go — entrypoint, Game struct, Update/Draw/Layout methods.
    • ship.goShip, Room types, placeholder ship constructor.
    • render.go — drawing helpers (rect fill, border, label).
  • Ship model. 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.
  • Placeholder ship content. 5 rooms — Pilot (front/left), Weapons (mid-upper), Shields (mid-center), MedBay (mid-lower), Engines (rear/right). Each rendered as a role-tinted filled rectangle + 1-px border + role label via Ebitengine's default text face from text/v2.
  • Build tooling — 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.
  • Browser shell. 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.

  • Grid-based rooms over freeform pixel coords: aligns with FTL's movement model; simplifies future click-to-move milestone.
  • Single package over cmd/ + internal/: small surface area, minimum friction. Split when a file crosses ~300 lines or a real subsystem boundary emerges.
  • Virtual resolution over native drawing: crisp pixel art at any window size; resizable-safe. Cost is a single 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.

#Invariants

  • All drawing uses virtual-resolution coordinates (640×360). No code draws in window-pixel units.
  • Single codebase builds unchanged for GOOS=js GOARCH=wasm and native targets; no platform-conditional imports (//go:build, GOOS-suffixed files) in this milestone.
  • Room positions and sizes are stored in tile units on Room. Pixel conversion happens only at render time.
  • No external asset files in milestone 1 — all placeholder art is code-drawn (filled rects + default text face).
  • ship.go does not import Ebitengine. Data types know nothing about rendering.

#Principles

  • YAGNI: no sub-packages, no interfaces, no ECS until a file crosses ~300 lines or a clear boundary emerges.
  • Fail fast on init errors (WASM bootstrap, font load). Panic is acceptable; no silent fallbacks.
  • Deterministic rendering: same ship state → same pixels. No hidden randomness in this milestone.
  • Separate data from rendering: ship/room types never call the renderer and never hold Ebitengine state.

#Plan

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.

#Phase 1 — Native app rendering the placeholder ship

  • 1.1 go.mod (modify)

    • Add dep github.com/hajimehoshi/ebiten/v2; run go mod tidy.
  • 1.2 ship.go (create)

    • Package 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.
    • Invariants: no Ebitengine imports in this file; positions and sizes in tile units only.
  • 1.3 render.go (create)

    • Package 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.
    • Invariant: all coordinate math converts tile → virtual pixels through TilePx; no window-pixel math.
  • 1.4 main.go (create)

    • Package 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.
    • Invariant: no platform-conditional imports; single build source for native + WASM.
  • Commit: scaffold game loop and placeholder ship render

#Phase 2 — WASM build and browser run target

  • 2.1 Makefile (create)

    • Targets: 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)

    • ~20-line WASM loader: <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.
    • Used by any static-serve path; wasmserve generates its own index when running the dev loop, so this file is for future release hosting and not on the play-web critical path.
  • 2.3 .gitignore (create)

    • Exclude bin/, web/main.wasm, web/wasm_exec.js.
  • Commit: add Makefile, WASM loader shell, play-web target

#Test strategy

No automated tests (per TDD: no). Manual acceptance:

  • Phase 1: go run . → window opens showing 5 labeled, colored rooms on a dark background.
  • Phase 2: make play-web → browser tab at :8080 shows identical render; make build produces a runnable bin/ftl-shape.

#Open questions / risks

  • 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.
  • WASM binary size (~10–20 MB) — known and out of scope per Design.

#Verify

Result: passed

Positive:

  • go build ./... → clean
  • go vet ./... → clean
  • GOOS=js GOARCH=wasm go build → produces valid wasm (WebAssembly MVP, ~12 MB)
  • make buildbin/ftl-shape Mach-O arm64
  • make build-wasmweb/main.wasm + web/wasm_exec.js copied from $GOROOT/lib/wasm/
  • make -n play-web → recipe expands to platform-detected open + wasmserve .
  • Native binary smoke: ran bin/ftl-shape 2s in background, window alive, clean kill
  • Browser smoke: served web/ 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)
  • No //go:build directives, no platform-suffixed *_js.go / *_windows.go / *_linux.go / *_darwin.go files
  • TilePx references confined to render.go — pixel conversion happens only at render time
  • No external asset files (png/jpg/wav/ttf/otf) anywhere in the repo
  • git status clean after make build + make build-wasm.gitignore correctly excludes all artifacts

#Conclusion

Outcome: skeleton milestone delivered — native window and browser WASM both render the 5-room placeholder ship at c71f238.

Invariants:

  • All drawing uses virtual 640×360 — TilePx references confined to render.go.
  • Single codebase for native + WASM — no //go:build, no platform-suffixed files; GOOS=js GOARCH=wasm go build passes.
  • Tile-unit storage, render-time pixel conversion — Room fields are pure ints; conversion in drawRoom only.
  • No external asset files — repo contains no image/audio/font binaries.
  • ship.go free of Ebitengine imports — verified by grep.

Future work:

  • Design prose mentions 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.
  • Git init was done inline before Phase 1 (task file predates any branch/worktree workflow); future milestones should set **Branch:** / **Worktree:** headers up front.