Merge goalie-tester worktree: modes, 3v3 goalies, OOB faceoffs, CF deploy.

Bring main menu (1v1/3v3), scrimmage with nets and goalies, dead-puck
whistles at the nearest faceoff circle, skater board re-entry, and
Cloudflare Workers/Pages deploy config. Keep main jersey gear stack and
compact goalie floaters.
This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:41:13 -05:00
parent 7d28facd68
commit f9aa382f30
18 changed files with 3121 additions and 130 deletions
+4
View File
@@ -2,3 +2,7 @@ node_modules/
dist/
shots/
.DS_Store
.wrangler/
.dev.vars
.dev.vars.*
!.dev.vars.example
+119
View File
@@ -0,0 +1,119 @@
# Deploying tilt
tilt is a **static Vite front-end** (Three.js + Box3D WASM). There is no API
server yet. Cloudflare **Workers Static Assets** is the primary target;
**Pages** is a one-liner alternative if you prefer Git/dashboard deploys.
## Prerequisites
1. Cloudflare account
2. Auth once on this machine:
```bash
npx wrangler login
# or: npm run cf:whoami
```
## Preferred: Workers (static assets)
Config lives in [`wrangler.jsonc`](wrangler.jsonc). Assets come from `dist/`
after a Vite production build.
```bash
npm install
npm run deploy:dry # validate upload without publishing
npm run deploy # build + wrangler deploy
```
That publishes a Worker named **`tilt`** serving:
| Path | App |
|------|-----|
| `/` | Game (menu → 1v1 / 3v3) |
| `/character.html` | Gear / animation studio |
URL shape after first deploy: `https://tilt.<your-subdomain>.workers.dev`
### Custom domain
Dashboard → Workers & Pages → **tilt** → Settings → Domains, or:
```bash
npx wrangler domains add tilt.example.com
```
(Exact CLI may vary by Wrangler version; dashboard is fine.)
## Alternative: Cloudflare Pages
Same build output, Pages project instead of a Worker:
```bash
npm run pages:deploy
# → wrangler pages deploy dist --project-name=tilt
```
Or connect the Git remote in the dashboard:
| Setting | Value |
|---------|--------|
| Production branch | `main` |
| Build command | `npm run build` |
| Build output directory | `dist` |
| Root directory | `/` (repo root) |
| Node version | 20+ |
`public/_headers` is copied into `dist/` by Vite and applies on both Workers
assets and Pages.
## Local production check
```bash
npm run build
npm run preview # Vite static server on dist/
# or, after wrangler is installed:
npx wrangler dev # serves assets via Workers runtime locally
```
## What is *not* deployed
- `test/`, `tools/` (Node harnesses, Puppeteer captures)
- `shots/`, `node_modules/`, source maps (unless you opt in)
- Server/netcode — still pure client sim
When you add an API later, keep this assets config and introduce a Worker
`main` (or Pages Functions) beside it; SPA/static hosting stays the same.
## CI sketch
```yaml
# e.g. Gitea Actions / GitHub Actions
- run: npm ci
- run: npm test
- run: npm run build
- run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```
Create an API token with **Workers Scripts:Edit** (and **Account:Read**).
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `wrangler login` / auth errors | `npx wrangler login` or set `CLOUDFLARE_API_TOKEN` |
| Blank page, missing WASM | Confirm `dist/assets/*.wasm` exists after build; hard-refresh |
| 404 on `/character` | Use `/character.html` (multi-page, not SPA rewrite) |
| Huge first load | Expected (~0.7 MB JS + ~0.8 MB WASM); cached after first visit |
| `node:module` warning in build | Harmless browser stub for box3ds Node path; WASM path is used |
## Files
| File | Role |
|------|------|
| `wrangler.jsonc` | Workers name, assets directory, observability |
| `public/_headers` | Cache + WASM content-type hints |
| `vite.config.js` | Multi-page entries (`index` + `character`) |
| `package.json` | `deploy`, `deploy:dry`, `pages:deploy` scripts |
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 ryan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+3
View File
@@ -24,8 +24,11 @@ npm install
npm run dev # http://localhost:5174
npm test # headless: sim, rink, AI, pose, input, physics, hits
npm run capture # boots the app headless and screenshots it into shots/
npm run deploy # vite build + Cloudflare Workers static assets
```
Cloudflare setup, Pages alternative, and CI notes: **[DEPLOY.md](DEPLOY.md)**.
In the browser:
| Xbox | keyboard | |
+606
View File
@@ -0,0 +1,606 @@
# Sports Framework — Expansion Plan
What you already have is not a hockey demo with spare parts. It is a **character +
physics + intent** stack that happens to be wired as ice hockey. The goal of this
document is to name the seams honestly, list what still has to become *sport-agnostic*,
and outline what would remain as **sport packs** so the same building blocks can
ship football, basketball, soccer, fighting, racing-on-foot, and contact sports
without forking the engine.
This is complementary to [ROADMAP.md](ROADMAP.md) (hockey product depth) and
[README.md](README.md) (current architecture). Here the question is:
> What would it take for *this package* to be the framework under *any* sport game?
---
## 1. The thesis
A general sports framework is not “one locomotion model that does everything.”
It is a **small set of contracts** plus **pluggable sport modules**:
| Layer | Owns | Sport-specific? |
|-------|------|-----------------|
| **Body stack** | skeleton, mesh, skinning, body style, ragdoll, proxy | mostly shared |
| **Equipment stack** | gear builders, sockets, coverage, colliders | shared builders; sport kits |
| **Motion stack** | pose buffer, crossfade, IK, override layers, clips later | shared runtime; sport poses |
| **Locomotion** | intent → velocity → footing | **one model per surface/gait family** |
| **Impact** | did hit / what kind / severity / handoff limp↔driven | shared core; sport rules |
| **Object play** | ball/puck possession, tool use, goals | sport packs |
| **AI** | roles, intent writers, formation | shared brain shell; sport tactics |
| **Arena** | bounds, surfaces, markings, static colliders | sport packs |
| **Rules / match** | clock, score, phases, sanctions | sport packs |
| **Studio / harness** | shot sheets, drills, capture, regression | shared tooling; sport catalogs |
Hockey already forces the hard problems (anisotropic friction, limb-level contact,
tool-in-hand IK, pure sim in `shared/`). That is why a framework *can* fall out of
this — but only if hockey stops being the type system.
---
## 2. What you already have (framework seeds)
These are reusable with little or no redesign.
### 2.1 Character body pipeline
- **23-bone A-pose skeleton** (`skeleton.js`) with regions, bone radii, segment graph
- **Procedural lofted body** (`body.js`) + **distance-field skinning** (`skinning.js`)
- **Body style morphs** mass / muscle / fat (`bodyStyle.js`) — already sport-neutral
- **Materials / team tints** (`materials.js`) — team identity, not hockey identity
### 2.2 Equipment geometry kit
- **`gearMesh.js`**: `loft`, `carvedShell`, `tube` — sport-agnostic mesh grammar
- **Socket + skinned cloth pattern** (hard shells on bones, cloth skinned with `aPart`/`aT`)
- **`hideCoveredBody`** coverage masks so kit replaces flesh
Hockey gear (`skaterGear`, `goalieGear`, `stick`) is content *on top of* that grammar.
### 2.3 Animation runtime
- **Pose buffer + state crossfade + override layers** (`skateAnimator.js`)
- **Two-bone analytic IK** (legs; goalie legs; stick grip reach partially)
- **Override layers that multiply spine** while replacing arms (stickwork) — correct
pattern for “keep locomoting while acting”
- **Animator does not own movement** — sim/proxy own position (critical for all sports)
### 2.4 Physics character loop
- **Proxy capsule for standing presence** + **kinematic ragdoll for pose/hits**
- **Limp handoff** with root re-expression so get-up does not double-apply slide
- **Limb-pair hit classification** from posed capsules (`hits.js`) — the right
separation of *did hit* vs *what hit*
- **Collision layers / tags** (`bridge.js`) — extensible KIND system
### 2.5 Intent seam
- **`applyIntent`** clamped entry (`skaterSim.js`) — controller, AI, and netcode all
write the same shape
- **Camera-relative stick** (`input.js`) — gamepad/keyboard already abstracted
### 2.6 Pure sim island
- **`shared/`** free of three.js, headless-testable, server-ready
- **Headless test culture** (pose, AI, physics, hits) + **capture / img2mesh** tools
### 2.7 Studio / reference loop (seed)
- **Character studio** (`character.html` + `img2mesh`) with pose presets, fixed views,
shot sheets, optional live drill (puck machine)
- Pattern: *author → capture sheet → drop refs → re-capture → diff by eye (or later pixel)*
---
## 3. What is still hockey-shaped (must peel off)
These block “any sport” until they become interfaces + plugins.
| Hardcoded today | Why it blocks generality |
|-----------------|---------------------------|
| `shared/skaterSim.js` is *the* locomotion | Running, plant-and-cut, swim, bike, skate all need different footing models |
| `shared/rink.js` is the arena | Rect + corner radius is not a pitch, court, ring, or track |
| `shared/ai.js` puck roles + rink half | Roles are puck/carrier/chaser; no ball/goal/zone abstraction |
| `possession.js` + stick blade | Magnetism dial is excellent, but assume blade + cylinder puck |
| `stick.js` grip-space tool | Need generic **held tool / ball socket / two-hand implement** |
| `hits.js` CAN_DELIVER / ice thresholds | Sport contact rules differ (tackle vs check vs foul vs strike) |
| `goalie` as special entity type | “Keeper” should be a role/locomotion profile, not only a hockey class |
| `match.js` / `shootout.js` | Match loop is sport session; not a generic phase machine |
| `input.js` Skill Stick / hockey stop | Sport action maps differ; need bindable action sets |
| Pose catalogs only skate / stick / goalie | Need gait families + sport action libraries |
| Physics world assumes ice plane + boards | Surfaces (friction, bounce), goals, posts, glass, hoops, cages |
| Package name / scripts assume hockey MVP | Cosmetics; real issue is import graph coupling |
---
## 4. Target architecture
```
packages/ (or src/ layered)
core/ math, rng, time, determinism helpers
body/ skeleton, body loft, skinning, bodyStyle, ragdoll, proxy
gear/ loft/shell/tube builders, socket attach, coverage
anim/ pose buffer, blend, IK solvers, layer stack, clip hooks
locomotion/ interface + models: skate, run, plant, crouch-shuffle, …
impact/ contact resolution, limb pairs, severity curves, handoff
object/ free bodies, attachment/magnetism, tools, projectiles
arena/ surfaces, bounds, static colliders, markings API
brain/ intent writer, roles, blackboard, formation helpers
match/ phase machine, clock, score, events (sport-agnostic shell)
input/ device → action set → intent
render/ materials, camera presets, arena mesh helpers
studio/ pose/view catalogs, capture API, ref sheets, drills
harness/ headless scenarios, golden metrics, capture regression
sports/
hockey/ rink, skate loco, stick, puck, check rules, shootout, AI
(future packs)
football/
basketball/
soccer/
fighting/ (Ludus-adjacent — already half-solved)
```
**Rule:** a sport pack may depend on the framework. The framework must not import a sport pack.
Hockey becomes the first pack that proves the contracts. Ludus fighting is the second
pack if contact + minigame reuse is the priority (see ROADMAP fighting note).
---
## 5. Contracts to extract (the real work)
### 5.1 Locomotion model (`ILocomotion`)
**Have:** carve/glide blade model + intent fields (`ix, iz, sprint, brake`).
**Need:**
```
createState(spawn, profile) → state
applyIntent(state, msg) // clamped, never trusts client
step(state, dt, env) // env: surface, contacts, clamps
readout(state) → { speed, gaitPhase, bank, plantL, plantR, … }
```
**Locomotion families to support eventually:**
| Family | Examples | Key physics |
|--------|----------|-------------|
| **Blade / edge** | ice hockey, figure, speed skate | anisotropic friction, carve |
| **Run plant** | soccer, basketball, football | foot plant, cut friction, acceleration |
| **Crouch / shuffle** | goalies, catchers, linemen | lateral step without full gait |
| **Contact-limited** | rugby ruck, board pin | reduced mobility under constraint |
| **Aerial / jump** | basketball, volleyball, headers | ballistic + land recovery |
| **Vehicle-assisted** | cycling, wheelchair, luge | separate later; not v1 |
**Left to build:**
- Shared `env` (surface µ, max slope, out-of-bounds clamp hooks)
- Foot plant bookkeeping for run gaits (world-planted feet vs mover-local skate feet)
- Profile data (top speed, accel, turn radius by sport / position / fatigue)
- Unit tests per model mirroring `test/skaterSim.mjs`
### 5.2 Footing & IK policy
**Have:** mover-local foot targets for skating; world-height leg IK for goalie pads.
**Need:**
- **`IFootPolicy`**: glide | plant | pivot | kick | slide
- World-space plant IK for walk/run (Ludus-style), without regressing skate glide
- Arm IK generic: **two-hand implement**, **one-hand hold**, **catch**, **block**
- Spine/look-at IK for tracking ball / opponent (broadcast + AI readability)
- Hand-to-object solve that reports *reach error* (you already see this on stick grip)
**Left to build:**
- Unified IK module (two-bone + simple FABRIK/pole vectors), shared by sports
- Constraint priorities (feet > implement > look) so layers dont fight
- Authoring helpers: target sockets in mover-local vs world
### 5.3 Pose / animation system generalization
**Have:** states + stick override layers + goalie stance set.
**Need:**
- **Locomotion graph** (idle, walk, run, sprint, cut, stop, jump, land, fall, getup)
- **Action layers** (shoot, pass, tackle, catch, block, celebrate) with blend masks
- **Clip adapter** interface early — procedural remains default; mocap/clips plug in
without rewriting sports (ROADMAP already names motion matching as the ceiling)
- Shared **get-up / knockdown / stagger** presentation states (physics-driven entry)
**Left to build:**
- Pose catalog format (JSON or pure JS) keyed by sport + role
- Layer stack API (replace vs multiply vs additive per bone set)
- Phase drivers from locomotion readout (gait phase, effort, bank)
- Optional motion-match slot (even if empty) so content pipeline isnt a rewrite later
### 5.4 Equipment framework
**Have:** builders + hockey kits + stick as special tool.
**Need:**
```
EquipmentDef {
id, sockets[], skinnedParts[], rigidParts[],
colliders[], coverage[], massAdd, tags[]
}
ToolDef extends Equipment // stick, bat, racket, gloves-as-weapons
WearableDef extends Equipment // helmet, pads, jersey, cleats
```
**Left to build:**
- Data-driven attach (bone name + local TRS + optional skin weights)
- Size from physique (you already scale gear off `physique` — generalize)
- Collider generation from mesh bounds or authored capsules
- Damage/armor hooks by `REGION` (skeleton already has regions “from GDD”)
- Asset path: procedural first, imported glTF meshes later behind same sockets
- **Reference-driven iteration**: shot sheet per equipment part (front/side/3q/detail)
### 5.5 Player assembly (`createAthlete`)
**Have:** `createSkater`, `createGoalie` — two assembly paths.
**Need:** one factory:
```
createAthlete({
seed, bodyStyle, team,
locomotion: 'skate' | 'run' | …,
role: 'skater' | 'keeper' | 'striker' | …,
loadout: EquipmentDef[],
animProfile,
impactProfile,
})
```
Roles swap locomotion, gear, AI profile, and action map — not hard forks of the character.
### 5.6 Impact / contact sports core
**Have:** proxy collision + limb pair + severity tiers + limp handoff + get-up fix.
**Need:**
- **Impact profiles** (thresholds, limbShare, bonuses, legal deliverers) per sport
- Legal / illegal contact classification as *rules callback*, not hardcoded hockey
- Multi-body pile stability (proxy equilibrium already soft under pressure)
- Grapple / pin / clinch states (football tackle finish, wrestling, board battle)
- Strike profiles for combat sports (Ludus) reusing limb pairs + reaction curve
- Surface hits (wall, boards, post) feeding same severity pipeline
- Injury / fatigue hooks off region + severity (data only; presentation later)
**Left to build:**
- `createImpactSystem({ profile, rules })` replacing hockey-tuned `HIT` constants
- Deliverer allow-lists as data
- Contested body state (pinned, held, blocked) separate from limp
- Harness scenarios: open ice hit, wall pin, tackle wrap, pile-up
### 5.7 Object / ball / tool play
**Have:** puck body, blade collider, magnetism possession dial, release step-clear.
**Need:**
- Generic **`PlayObject`**: sphere / cylinder / prolate (ball types), mass, restitution
- **Attachment models**: hard parent, soft magnetism, carry socket, two-hand cradle
- **Release models**: impulse from tool velocity, throw arc, kick from foot bone
- Contested possession (you already noted nearest-index-wins is too coarse)
- Multi-object (only one puck today)
**Left to build:**
- `object/possession.js` parameterized (radius, catch cone, break force, magnetism)
- Tool velocity sampling at release (stick already aims; generalize to foot/hand)
- Interaction matrix: body part × object × surface
- Sport rules for out-of-bounds / last touch (thin event layer)
### 5.8 Arena / surface system
**Have:** NHL rink numbers, board outline, ice clamp, markings texture, nets.
**Need:**
```
ArenaDef {
bounds, // polygon or compound
surfaces[], // friction, restitution, name
staticColliders[],
goals[] | targets[],
spawns[],
markings, // render only
}
```
**Left to build:**
- Containment for rect, rounded-rect, circle, custom polyline
- Surface queries under feet (ice vs turf vs hardwood changes loco)
- Goal/score volumes as triggers (net is hockey-specific geometry)
- Camera volumes / broadcast rails optional metadata
### 5.9 AI brain shell
**Have:** waypoint steer, personal space, board lookahead, carry/chase/support/defend
via nearest-to-puck, intent-only outputs.
**Need:**
```
Brain {
sense(world) → facts
rolePolicy(facts) → role
tactic(role, facts) → goal
motor(goal) → intent + actions
}
```
**Left to build:**
- Blackboard / facts schema (ball, goals, teammates, opponents, clock, score)
- Role set as data per sport (not four hockey enums only)
- Formation / spacing utilities (slots relative to ball and own goal)
- Difficulty as parameter on reaction lag, aim cone, decision rate
- Keeper brain as role using shuffle locomotion (generalize goalie)
- Headless scenario tests: “defend lead”, “press high”, “box out” per pack
Hockey AI depth (forecheck systems, etc.) stays in the hockey pack / ROADMAP.
### 5.10 Match / rules shell
**Have:** `createMatch` loop, shootout phase machine, goal detection helpers.
**Need:**
- Generic **phase machine**: warmup → live → stoppage → restart → end
- Event bus: goal, foul, out, period, possession change, hit
- Score / clock / period as configurable
- Restart spawns from arena + rules (faceoff, kickoff, freethrow, scrum)
**Left to build:**
- `match/runtime.js` with sport rules plugin
- Deterministic event log (gates replay + netcode later)
- Minimal HUD adapter (score/clock) sport-agnostic
### 5.11 Input / action maps
**Have:** Xbox + keyboard, Skill Stick gestures, rumble on hit.
**Need:**
- Action set per sport (`move`, `sprint`, `primary`, `secondary`, `skillAxis`, …)
- Gesture recognizers as plugins (Skill Stick shoot is one plugin)
- Rebind + dual-input last-wins (keep current behavior)
### 5.12 Studio, reference sheets, training harness
This is the differentiator you called out. Today it is hockey-shaped but the **loop**
is correct.
**Have:**
- Live studio with pose/view catalogs
- CLI shot sheet (`img2mesh`)
- Puck machine drill for goalie
- Headless tests + `capture` screenshots
- `hitprobe` for contact
**Need for framework-grade training harness:**
| Capability | Purpose |
|------------|---------|
| **Subject registry** | any athlete role + loadout, not only player/goalie |
| **Pose/view catalogs as data** | sport packs register entries |
| **Reference slots** | `ref/<sport>/<subject>/<pose>_<view>.png` side-by-side |
| **Diff mode** | flip / onion-skin / histogram vs previous capture |
| **Drill factory** | spawn scenario (1v1, shooting gallery, tackle pad, PK) |
| **Metric probes** | blade height, foot plant error, reach error, joint limits, timing |
| **Scenario packs** | headless scripts asserting metrics, not only “no NaN” |
| **Optional photo-guided authoring** | import ref image as camera-aligned overlay (not auto-mesh) |
| **Later: mesh-from-image assist** | silhouette / part labels → loft section hints (research; not blocking) |
**Important scope note:** true **image → production mesh** (full photogrammetry /
generative retopo) is a product of its own. The framework should treat **reference
photos and sheets as the iteration contract**, with procedural gear remaining the
authoritative runtime asset until a mesh pipeline earns its keep.
**Left to build (practical order):**
1. Registry + data-driven catalogs (so new sports dont edit `img2mesh.mjs` internals)
2. Side-by-side ref overlay in studio + CLI compare report
3. Metric HUD (contact points, IK targets, capsule overlays — debug already half there)
4. Drill API used by shootout machine, then tackle/shot drills for other packs
5. Golden metric tests (blade height style) per sport pack
6. Optional: capture matrix in CI on PR (you already have the capture tool)
---
## 6. Sport packs — what each still needs on top of the framework
Once contracts exist, each sport is mostly **content + rules + AI policy**, not a new engine.
### 6.1 Ice hockey (first pack — mostly present)
**Done enough:** skate loco, rink, stick, puck, checks, shootout, basic AI, goalie MVP.
**Pack backlog:** see ROADMAP (full 5v5 rules, goalie depth, positional AI, possession feel).
### 6.2 Fighting / combat (highest reuse from Ludus + impact core)
- Strike / block / grab action layers
- Ring/cage arena
- Stamina and limb disable via `REGION`
- Existing fighting system port (ROADMAP already flags this)
### 6.3 Soccer
- Run plant locomotion + ball foot/chest/head contacts
- Large pitch arena, goals, out/throw-in/corner rules
- No (or soft) limb impact; shoulder challenge profile
- AI: formations, offside line fact, keeper dive (shuffle + dive loco)
### 6.4 Basketball
- Run + jump + plant, ball carry/dribble magnetism variant
- Court, hoop trigger, shot arc from release
- Screen / box-out contact (low severity impact profile)
- AI: pick-and-roll roles, spacing
### 6.5 American football / rugby
- Run + explosive cut; tackle as grapple-to-limp pipeline
- Field, downs/ruck rules plugin
- Heavy impact profile; pile-up stability critical
- AI: playbooks as scripted formations + read trees
### 6.6 Basketball-adjacent / volleyball / handball
- Shared run + jump + catch/throw object model
- Different arenas and score targets
### 6.7 Non-goals for v1 framework
- Full vehicle sports, water sports, golf swing fidelity, licensed broadcast packages
- Complete mocap library (architecture must allow it; content is separate)
---
## 7. Gap list — prioritized build order
Phased so each phase ships a **usable contract** and keeps hockey working as the
proving pack (strangler pattern: extract behind interfaces, re-home hockey).
### Phase 0 — Inventory & seams (days)
- [ ] Draw import graph: mark `shared/*` vs sport-coupled modules
- [ ] Freeze contracts in a short `docs/contracts.md` (or this file §5 as source)
- [ ] Prove Box3D determinism (ROADMAP) — gates multiplayer *and* harness replay
- [ ] Decide monorepo layout (`sports/hockey` vs stay single package with folders)
### Phase 1 — Athlete + gear core (12 weeks)
- [ ] `createAthlete` + loadout list; skater/goalie become configs
- [ ] EquipmentDef schema; move stick/pads to data-ish modules
- [ ] Physique-driven sizing API documented
- [ ] Studio subject registry reads loadouts
### Phase 2 — Locomotion interface (12 weeks)
- [ ] Extract `ILocomotion` from `skaterSim`
- [ ] Keep skate model as implementation #1
- [ ] Stub `runPlant` model good enough for a mannequin jog (even ugly)
- [ ] Foot policy switch (glide vs plant) wired to animator
### Phase 3 — Anim / IK platform (23 weeks)
- [ ] Shared IK solvers module
- [ ] Layer stack API (locomotion / action / reaction / getup)
- [ ] Reaction/stagger as layers driven by impact system
- [ ] Clip adapter interface (null implementation OK)
### Phase 4 — Impact + object cores (23 weeks)
- [ ] Impact profiles + rules callbacks
- [ ] Generic play object + possession magnetism
- [ ] Tool release sampling
- [ ] Contested capture v1
### Phase 5 — Arena + match shell (12 weeks)
- [ ] ArenaDef; rink becomes hockey arena pack
- [ ] Phase machine + event log
- [ ] Score/clock/HUD adapters
### Phase 6 — Brain shell (2+ weeks, ongoing content)
- [ ] Facts blackboard + role policy
- [ ] Formation helpers
- [ ] Keeper role using shared pieces
- [ ] Scenario harness for AI regressions
### Phase 7 — Studio / training harness productize (parallel after Phase 1)
- [ ] Data-driven pose/view catalogs
- [ ] Reference overlay + capture matrix
- [ ] Metric probes + golden tests
- [ ] Drill factory (shooting, tackling, 1v1)
- [ ] Optional ref-image camera plate
### Phase 8 — Second sport pack (proof)
Pick **one** of:
- **Fighting** (max reuse of impact + Ludus), or
- **Soccer prototype** (max pressure on run loco + ball object)
Success criterion: second sport does **not** fork `skeleton`, `ragdoll`, `gearMesh`,
`hits` core, or studio capture — only pack content + profiles.
---
## 8. What “done” looks like for the framework (not for a sport)
You can claim a sports framework when **all** of these are true:
1. **New athlete** = skeleton + bodyStyle + loadout + loco profile + anim profile
2. **New equipment piece** = sections/sockets (+ optional collider) + studio views
3. **New contact rule** = impact profile + rules callback, no hit-system fork
4. **New ball sport** = PlayObject + possession params + arena goals + AI facts
5. **New locomotion** = one module implementing `ILocomotion` + foot policy
6. **New sport AI** = roles writing intents/actions into the same seam as the pad
7. **Regression** = headless metrics + shot sheets vs reference photos for that pack
8. **Hockey still runs** as a pack on top of the same cores
Not required for “framework done”: full NHL parity, licensing, netcode, franchise modes
(those are product; see ROADMAP).
---
## 9. Risks & non-goals
| Risk | Mitigation |
|------|------------|
| Over-abstract before second sport | Extract only when hockey + one other pack demand the seam |
| Run gait looks like skating forever | Foot plant policy is a hard gate for sport #2 |
| Proxy pile-ups | Dedicated multi-body scenarios before rugby/football |
| Mocap FOMO | Keep procedural + layer API; add clips when content exists |
| img2mesh becomes “AI will make the gear” | Refs guide humans/tools; runtime assets stay authored procedural/glTF |
| Determinism ignored | Prove early; harness replays depend on it |
| God-object `match.js` | Event log + rules plugin from the start of Phase 5 |
**Non-goals:** replacing Unity/Unreal; full photogrammetry pipeline; every Olympic sport
in year one; network-complete framework before single-player packs feel good.
---
## 10. Suggested near-term decisions
1. **Keep hockey playable on main** while extracting — no big-bang rewrite.
2. **Name the framework** (e.g. still `tilt` core + `sports/hockey`, or promote Ludus-lineage
body stack to a shared package both Ludus and tilt consume).
3. **Second pack choice** determines Phase 24 emphasis:
- Fighting → impact, reaction layers, stamina
- Soccer → run plant, ball object, large arena
4. **Studio first among tools** — equipment and poses improve only as fast as the
ref-sheet loop; that loop is already the right shape.
5. **Do not wait for mocap** to generalize animation runtime; wait for mocap to *fill* it.
---
## 11. One-page checklist — “left to expand”
**Core extractions**
- [ ] Locomotion interface + run/plant model
- [ ] Foot / arm / look IK module
- [ ] Anim layer stack + clip adapter
- [ ] Athlete factory + data loadouts
- [ ] EquipmentDef / ToolDef schema
- [ ] Impact profiles + rules hooks
- [ ] PlayObject + generic possession
- [ ] ArenaDef + surfaces
- [ ] Brain blackboard + roles
- [ ] Match phase machine + events
- [ ] Action-map input
**Harness / content pipeline**
- [ ] Sport-registered studio catalogs
- [ ] Reference photo overlay + capture matrix
- [ ] Metric probes + golden pose/contact tests
- [ ] Drill factory for training scenarios
**Proof**
- [ ] Hockey re-homed as pack
- [ ] Second sport pack without core forks
- [ ] Determinism + replay of harness scenarios
**Explicitly still sport content (never “framework only”)**
- Poses, gear shapes, AI tactics, rules edge cases, audio, broadcast, modes, licensing
---
## 12. Bottom line
You are closer than a greenfield sports engine because the **hard abstractions are
already proven in anger**:
- intent-driven locomotion
- animator subordinate to physics
- limb-level contact from posed ragdolls
- limp ↔ driven handoff without teleports
- tool-in-hand dependency order
- possession as a dial
- pure sim + headless tests + reference shot sheets
What remains is not “invent sports tech.” It is **peeling hockey off the type system**,
turning each proven spike into a **contract + pack content**, and making the
**studio/harness** the way every new sport earns visual and mechanical correctness
from reference photos and scenario metrics.
Hockey ROADMAP remains the depth plan for pack #1. This document is the breadth plan
for the chassis underneath packs #1…N.
+64 -1
View File
@@ -21,13 +21,76 @@
#hud { position:absolute; left:14px; top:12px; color:#8fb4d4; font-size:12px;
line-height:1.5; white-space:pre; pointer-events:none; text-shadow:0 1px 2px #000; }
#boot { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:10; }
color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:20; }
#menu {
position:absolute; inset:0; z-index:15;
display:flex; flex-direction:column; align-items:center; justify-content:center;
background:radial-gradient(ellipse at 50% 35%, #121a26 0%, #0a0e14 70%);
color:#c8dcea; pointer-events:auto;
}
#menu[hidden] { display:none; }
#menu .brand {
letter-spacing:0.55em; font-size:13px; color:#6ea8dc; margin:0 0 10px;
text-indent:0.55em;
}
#menu h1 {
margin:0 0 8px; font-size:42px; font-weight:600; letter-spacing:0.08em;
color:#e8f2fa; text-shadow:0 2px 24px rgba(80,140,200,0.35);
}
#menu .tag {
margin:0 0 36px; font-size:12px; color:#6a849c; letter-spacing:0.12em;
}
#menu .choices {
display:flex; flex-direction:column; gap:12px; width:min(360px, 86vw);
}
#menu button.choice {
appearance:none; border:1px solid #2a4058; background:#121c28;
color:#d4e6f5; font:inherit; font-size:15px; letter-spacing:0.06em;
padding:16px 20px; text-align:left; cursor:pointer;
border-radius:4px; transition:border-color .12s, background .12s, transform .08s;
}
#menu button.choice:hover, #menu button.choice:focus-visible {
outline:none; border-color:#5a9fd4; background:#172433;
}
#menu button.choice:active { transform:scale(0.99); }
#menu button.choice .label {
display:block; font-size:16px; color:#eef6fc; margin-bottom:4px;
}
#menu button.choice .hint {
display:block; font-size:11px; color:#6a849c; letter-spacing:0.04em;
}
#menu button.choice .key {
float:right; margin-top:2px; color:#5a9fd4; font-size:12px;
border:1px solid #2a5070; border-radius:3px; padding:2px 7px;
}
#menu .foot {
margin-top:28px; font-size:11px; color:#4a6074; letter-spacing:0.08em;
}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="hud"></div>
<div id="menu" hidden>
<div class="brand">TILT</div>
<h1>HOCKEY</h1>
<p class="tag">pick a mode</p>
<div class="choices">
<button type="button" class="choice" data-mode="1v1" id="mode-1v1">
<span class="key">1</span>
<span class="label">1-on-1</span>
<span class="hint">Shootout — one shooter, one goalie, alternating ends</span>
</button>
<button type="button" class="choice" data-mode="3v3" id="mode-3v3">
<span class="key">2</span>
<span class="label">3-on-3</span>
<span class="hint">Full ice — nets, goalies, hits, pass, shoot for a score</span>
</button>
</div>
<p class="foot">Esc returns here · pad or keyboard once you&rsquo;re in</p>
</div>
<div id="boot">TILT&hellip;</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1541 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -12,7 +12,11 @@
"capture": "node tools/capture.mjs",
"img2mesh": "node tools/img2mesh.mjs",
"img2mesh:player": "node tools/img2mesh.mjs --subject player",
"img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie"
"img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie",
"deploy": "npm run build && wrangler deploy",
"deploy:dry": "npm run build && wrangler deploy --dry-run",
"pages:deploy": "npm run build && wrangler pages deploy dist --project-name=tilt",
"cf:whoami": "wrangler whoami"
},
"dependencies": {
"box3d.js": "^0.0.2",
@@ -20,6 +24,7 @@
},
"devDependencies": {
"puppeteer-core": "^25.4.0",
"vite": "^8.1.5"
"vite": "^8.1.5",
"wrangler": "^4.118.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
# Cloudflare Pages / Workers static asset headers.
# Vite copies everything under public/ into dist/ on build.
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: interest-cohort=()
# Hashed Vite assets: long cache. WASM must load as application/wasm.
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*.wasm
Content-Type: application/wasm
Cache-Control: public, max-age=31536000, immutable
# HTML entry points stay fresh so deploys pick up new asset hashes quickly.
/
Cache-Control: public, max-age=0, must-revalidate
/index.html
Cache-Control: public, max-age=0, must-revalidate
/character.html
Cache-Control: public, max-age=0, must-revalidate
+59
View File
@@ -32,6 +32,65 @@ export const MARKINGS = Object.freeze({
zoneDotX: 20.2,
});
/**
* All nine faceoff dots: centre, four neutral-zone, four end-zone.
* Order is stable so tests and HUD labels can index if they want.
*/
export const FACEOFF_DOTS = Object.freeze([
Object.freeze({ id: 'centre', x: 0, z: 0 }),
Object.freeze({ id: 'nz-pp', x: MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-pm', x: MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-mp', x: -MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-mm', x: -MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-pp', x: MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-pm', x: MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-mp', x: -MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-mm', x: -MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }),
]);
/** Nearest faceoff dot to a world point — where a whistle drops the next draw. */
export function nearestFaceoffDot(x, z) {
let best = FACEOFF_DOTS[0];
let bestD = Infinity;
for (const d of FACEOFF_DOTS) {
const dd = (d.x - x) * (d.x - x) + (d.z - z) * (d.z - z);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
return best;
}
/**
* Is the puck still in play?
*
* Horizontal: must be on the ice surface (small inset so "on the boards" is
* still playable, but over the glass / past the outline is dead).
* Vertical: above the glass, under the slab, or impossibly high is unplayable.
*/
export function puckPlayable(x, y, z, radius = 0.0381) {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return { ok: false, reason: 'nan' };
}
// Far outside the barn entirely (escaped continuous collision).
if (Math.abs(x) > RINK.halfX + 4 || Math.abs(z) > RINK.halfZ + 4) {
return { ok: false, reason: 'escaped' };
}
// Under the ice or stuck in the slab.
if (y < -0.15) return { ok: false, reason: 'under' };
// Over the glass. Boards are ~1.07 m; glass is visual only above that.
if (y > RINK.boardHeight + RINK.glassHeight * 0.55) {
return { ok: false, reason: 'over' };
}
// Centre past the board line — the puck has left the playing surface.
// Tiny slack so a rattle against the boards does not whistle every contact.
if (rinkPenetration(x, z, 0).dist > radius * 0.75) {
return { ok: false, reason: 'out' };
}
return { ok: true, reason: '' };
}
/**
* Centre of the corner arc nearest (x, z), and the sign of the quadrant.
* Points outside the straight sections belong to exactly one corner.
+8 -11
View File
@@ -698,23 +698,20 @@ export function buildGoalieGear(mats, skelData, phys) {
);
// Shoulder floaters — parented to the upper arms, aligned to the A-pose axis
// so they actually sit on the arm instead of hovering beside it.
// so they actually sit on the arm instead of hovering beside it. Keep these
// compact, like the skater's deltoid caps: the sleeve supplies the dressed
// silhouette, while a long rigid arm pad tears through the skinned cloth in
// butterfly and reach poses.
function makeFloater(side) {
const g = new THREE.Group();
g.name = `floater${side}`;
const cap = loft([
S(V(0, 0.05, 0.01), 0.068, 0.065, 3, PAL.base),
S(V(0, -0.02, 0.012), 0.084, 0.078, 4),
S(V(0, -0.08, 0.01), 0.079, 0.072, 4),
S(V(0, -0.145, 0.008), 0.068, 0.061, 3),
S(V(0, 0.04, 0.008), 0.058, 0.054, 3, PAL.base),
S(V(0, -0.025, 0.01), 0.068, 0.064, 4),
S(V(0, -0.09, 0.008), 0.062, 0.058, 4),
S(V(0, -0.14, 0.006), 0.05, 0.046, 3),
], { radial: 16, sub: 4 });
g.add(mesh(cap, mats.painted, `floater${side}Cap`));
const arm = loft([
S(V(0, -0.16, 0.006), 0.064, 0.059, 3, PAL.base),
S(V(0, -0.26, 0.004), 0.058, 0.053, 3),
S(V(0, -0.315, 0.002), 0.046, 0.042, 3, PAL.trim),
], { radial: 14, sub: 4 });
g.add(mesh(arm, mats.painted, `floater${side}Arm`));
alignTo(g, ARM_DIR[side]);
pieces.push(g);
return g;
+95 -13
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three';
import { createSkater } from '../character/skater.js';
import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
import { applyIntent, createSkaterState, stepSkater } from '../../shared/skaterSim.js';
import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js';
import { stickToWorld } from './input.js';
import { createHitResolver } from './hits.js';
import { PUCK, createPuck } from '../physics/puck.js';
@@ -9,6 +9,7 @@ import { NET, goalLineX } from '../../shared/net.js';
import { createPossession } from './possession.js';
import { makeRng } from '../core/rng.js';
import { clamp, wrapAngle } from '../../shared/scalar.js';
import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js';
/**
* The match loop.
@@ -248,6 +249,41 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
chaser: new Array(teams).fill(null),
};
/**
* One-way board hop: outside → ice only. Boards still block leaving.
* @returns {boolean}
*/
function reenterSkaters() {
let any = false;
for (let i = 0; i < count; i++) {
const s = states[i];
// Limp bodies ride the ragdoll; get-up re-homes the proxy to the pelvis.
if (skaters[i].limp) continue;
const pen = rinkPenetration(s.x, s.z, SKATE.radius * 0.85);
if (pen.dist <= 0.02) continue;
any = true;
const inset = pen.dist + 0.08;
s.x += pen.nx * inset;
s.z += pen.nz * inset;
// Kill velocity going further out of the rink.
const outward = s.vx * -pen.nx + s.vz * -pen.nz;
if (outward > 0) {
s.vx += pen.nx * outward;
s.vz += pen.nz * outward;
}
// Hop inward so they clear the wall instead of grinding it.
s.vx += pen.nx * 1.2;
s.vz += pen.nz * 1.2;
const hopX = s.vx;
const hopZ = s.vz;
skaters[i].proxy?.teleport(s.x, s.z);
s.vx = hopX;
s.vz = hopZ;
skaters[i].proxy?.write(s);
}
return any;
}
/** @param {number} dt */
function update(dt) {
const pp = puck.position();
@@ -373,6 +409,10 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
puck.setVelocity(v.x * k, v.y * k, v.z * k);
}
// Skaters who end up outside (over the boards, tunnel, get-up glitch) hop
// back onto the ice. One-way only: boards still block leaving the normal way.
reenterSkaters();
// ---- 3. hits, knockdowns and getting up --------------------------------
hits.tick(dt);
for (let i = 0; i < count; i++) {
@@ -481,24 +521,66 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
return states.filter((s) => s.team === index);
},
/** Drop everyone back on their spawn, momentum cleared. */
reset() {
for (let i = 0; i < count; i++) {
const spawn = spawns[i];
// Anyone lying on the ice has to be stood up before being placed, or
// their proxy stays disabled and they spawn as a corpse.
if (skaters[i].limp) skaters[i].getUp(states[i]);
Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw });
skaters[i].proxy?.teleport(spawn.x, spawn.z);
brains[i].target = null;
/**
* Drop everyone for a faceoff at a given dot (default: centre ice).
* Accepts a faceoff-dot object or raw `{x,z}`.
*/
faceoffAt(spot = FACEOFF_DOTS[0]) {
const fx = spot?.x ?? 0;
const fz = spot?.z ?? 0;
// Team 0 stands on the X side of the puck, team 1 on +X — each faces in.
const byTeam = [[], []];
for (let i = 0; i < count; i++) byTeam[states[i].team]?.push(i);
for (let t = 0; t < teams; t++) {
const side = t === 0 ? -1 : 1;
const ids = byTeam[t] ?? [];
for (let k = 0; k < ids.length; k++) {
const i = ids[k];
// First skater is the draw; the rest fan back and wide.
const along = k === 0 ? 1.05 : 2.4 + (k - 1) * 1.1;
const lateral = k === 0 ? 0 : ((k % 2 === 1 ? 1 : -1) * (0.9 + Math.floor((k - 1) / 2) * 1.2));
let x = fx + side * along;
let z = fz + lateral;
// Keep the lineup on the ice if the dot is near the boards.
const pen = rinkPenetration(x, z, SKATE.radius + 0.15);
if (pen.dist > 0) {
x += pen.nx * (pen.dist + 0.05);
z += pen.nz * (pen.dist + 0.05);
}
if (skaters[i].limp) skaters[i].getUp(states[i]);
Object.assign(states[i], {
x, z, vx: 0, vz: 0,
yaw: side > 0 ? -Math.PI / 2 : Math.PI / 2,
ix: 0, iz: 0, sprint: false, brake: false,
});
skaters[i].proxy?.teleport(x, z);
brains[i].target = null;
}
}
recentHits.length = 0;
recentPlays.length = 0;
// Faceoff: puck at centre ice, dead.
possession.reset();
puck.place(0, 0.05, 0);
puck.place(fx, PUCK.thickness / 2 + 0.01, fz);
},
/** Centre-ice faceoff — the default restart. */
reset() {
this.faceoffAt(FACEOFF_DOTS[0]);
},
reenterSkaters,
/** True when a skater is clearly outside the playing surface. */
skaterOutOfBounds(i) {
const s = states[i];
if (!s) return false;
return !insideRink(s.x, s.z, -0.15);
},
nearestFaceoffDot,
destroy() {
hits.destroy();
puck.destroy();
+188
View File
@@ -0,0 +1,188 @@
import * as THREE from 'three';
import { createGoalie } from '../character/goalie.js';
import { buildNetMesh, createNet } from '../physics/net.js';
import { isGoal } from '../../shared/net.js';
import { FACEOFF_DOTS, nearestFaceoffDot, puckPlayable } from '../../shared/rink.js';
import { PUCK } from '../physics/puck.js';
/**
* 3-on-3 with nets and goalies.
*
* Continuous play: goals, goalie covers, and dead pucks (out of bounds /
* unplayable) all whistle and restart at a faceoff. Goals restart at centre;
* OOB restarts at the nearest faceoff circle.
*/
export const SCRIMMAGE = {
/** Hold the scoreboard message before the faceoff, seconds. */
resultTime: 2.0,
/**
* Goalie covers the puck and freezes play when it is this slow, m/s.
* Matches the shootout idea: a sealed catch ends the rush.
*/
coverSpeed: 2.8,
/**
* How long a skater can sit clearly outside the boards before we whistle
* (they normally hop back in on their own).
*/
skaterOobGrace: 0.55,
};
/**
* @param {{ scene: import('three').Scene, physics: object, match: object }} opts
*/
export function createScrimmage({ scene, physics, match }) {
const { puck, possession, states, skaters } = match;
const nets = [createNet(physics, 1), createNet(physics, -1)];
const netMeshes = [buildNetMesh(scene, 1), buildNetMesh(scene, -1)];
// end +1 (+X) is defended by team 1; end 1 by team 0.
const goalies = {
1: createGoalie(physics, scene, { end: 1, index: 40, team: 1 }),
'-1': createGoalie(physics, scene, { end: -1, index: 41, team: 0 }),
};
const state = {
/** 'live' | 'goal' | 'cover' | 'oob' | 'skater_oob' */
phase: 'live',
score: [0, 0],
/** Last stoppage, for the HUD. */
last: null,
clock: 0,
/** Where the next faceoff drops after this stoppage. */
nextFaceoff: FACEOFF_DOTS[0],
};
/** Per-skater time spent outside the ice. */
const oobAge = new Float64Array(states.length);
const _puckPos = new THREE.Vector3();
function faceoff() {
const spot = state.nextFaceoff ?? FACEOFF_DOTS[0];
match.faceoffAt(spot);
goalies[1].reset();
goalies[-1].reset();
state.phase = 'live';
state.clock = 0;
oobAge.fill(0);
}
/**
* @param {'goal'|'cover'|'oob'|'skater_oob'} kind
* @param {string} detail
* @param {{ x: number, z: number } | null} faceoffSpot
*/
function stoppage(kind, detail = '', faceoffSpot = null) {
let team = null;
if (kind === 'goal') {
// Goal at +X end is team 0; at X is team 1.
team = detail === '+x' ? 0 : 1;
state.score[team]++;
state.nextFaceoff = FACEOFF_DOTS[0];
} else if (faceoffSpot) {
state.nextFaceoff = nearestFaceoffDot(faceoffSpot.x, faceoffSpot.z);
} else {
state.nextFaceoff = FACEOFF_DOTS[0];
}
state.phase = kind;
state.clock = SCRIMMAGE.resultTime;
state.last = {
kind,
detail,
team,
score: [...state.score],
faceoff: state.nextFaceoff,
};
// Kill puck motion so it does not rattle around during the hold.
puck.setVelocity(0, 0, 0);
possession.reset();
}
function update(dt) {
_puckPos.copy(puck.position());
goalies[1].update(dt, _puckPos);
goalies[-1].update(dt, _puckPos);
if (state.phase !== 'live') {
state.clock -= dt;
if (state.clock <= 0) faceoff();
return;
}
// Goals first — a covered puck that also crossed still counts as a goal.
if (isGoal(_puckPos, 1, PUCK.radius)) {
stoppage('goal', '+x');
return;
}
if (isGoal(_puckPos, -1, PUCK.radius)) {
stoppage('goal', '-x');
return;
}
// Dead puck: out of bounds or unplayable → whistle, nearest circle.
const play = puckPlayable(_puckPos.x, _puckPos.y, _puckPos.z, PUCK.radius);
if (!play.ok) {
stoppage('oob', play.reason, { x: _puckPos.x, z: _puckPos.z });
return;
}
// Either goalie freezes a slow puck in the body — whistle, faceoff.
const speed = puck.speed();
if (speed < SCRIMMAGE.coverSpeed) {
if (goalies[1].covers(_puckPos)) {
stoppage('cover', 'away goalie', { x: _puckPos.x, z: _puckPos.z });
return;
}
if (goalies[-1].covers(_puckPos)) {
stoppage('cover', 'home goalie', { x: _puckPos.x, z: _puckPos.z });
return;
}
}
// Skaters hop back over the boards themselves (match.reenterSkaters). If
// someone is still clearly outside after a short grace — wrong side of the
// glass and stuck — whistle and draw at the nearest circle.
for (let i = 0; i < states.length; i++) {
if (skaters[i].limp) {
oobAge[i] = 0;
continue;
}
if (match.skaterOutOfBounds(i)) {
oobAge[i] += dt;
if (oobAge[i] >= SCRIMMAGE.skaterOobGrace) {
stoppage('skater_oob', states[i].name, { x: states[i].x, z: states[i].z });
return;
}
} else {
oobAge[i] = 0;
}
}
}
return {
state,
goalies,
nets,
netMeshes,
update,
faceoff,
/** Full reset of score + ice (centre faceoff). */
reset() {
state.score = [0, 0];
state.last = null;
state.nextFaceoff = FACEOFF_DOTS[0];
faceoff();
},
destroy() {
for (const n of nets) n.destroy();
for (const m of netMeshes) scene.remove(m);
goalies[1].destroy();
goalies[-1].destroy();
},
};
}
+327 -100
View File
@@ -7,18 +7,24 @@ import { createMatch } from './game/match.js';
import { createInput } from './game/input.js';
import { describeHit } from './game/hits.js';
import { createShootout } from './game/shootout.js';
import { createScrimmage } from './game/scrimmage.js';
import { RINK } from '../shared/rink.js';
/**
* Spike 1 boot: three AI skaters on a rink.
* Shell: renderer, lights, main menu, and the active mode loop.
*
* Everything gameplay-shaped lives in `game/match.js`; this file is the shell —
* renderer, lights, resize, the frame loop and a small debug HUD.
* Modes:
* 1v1 — shootout (one shooter vs goalie, alternating teams)
* 3v3 — six skaters + nets + goalies, open play with scoring
*
* Everything gameplay-shaped lives in `game/`; this file wires the shell and
* tears modes down cleanly so Esc can return to the menu.
*/
const canvas = document.getElementById('stage');
const boot = document.getElementById('boot');
const hud = document.getElementById('hud');
const menuEl = document.getElementById('menu');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.shadowMap.enabled = true;
@@ -82,62 +88,221 @@ resize();
const stats = { fps: 0, steps: 0, top: 0 };
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
async function boot3() {
/** @typedef {'1v1' | '3v3'} GameMode */
const RUMBLE = {
knockdown: [1.0, 0.7, 260],
stagger: [0.55, 0.35, 150],
bump: [0.22, 0.12, 70],
};
function parseModeFromUrl() {
const raw = new URLSearchParams(location.search).get('mode');
if (raw === '1v1' || raw === '1on1' || raw === 'shootout') return '1v1';
if (raw === '3v3' || raw === '3on3' || raw === 'scrimmage') return '3v3';
return null;
}
async function bootApp() {
await initPhysics();
const physics = createPhysicsWorld();
buildRink(scene);
const match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
const puckView = buildPuckMesh(scene, PUCK);
// The shootout owns the nets and the goalies, and drives its own kinematic
// bodies inside the physics substep.
const shootout = createShootout({ scene, physics, match });
match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
const input = createInput(window);
// One live input object, refreshed each frame and read by the match.
const stick = input.state;
// Rumble on contact the player is part of. Strength tracks the outcome, so
// the pad tells you whether you laid someone out or just brushed them, and
// taking one buzzes harder than giving one.
const RUMBLE = {
knockdown: [1.0, 0.7, 260],
stagger: [0.55, 0.35, 150],
bump: [0.22, 0.12, 70],
};
/** @type {GameMode | null} */
let mode = null;
/** @type {ReturnType<typeof createMatch> | null} */
let match = null;
/** @type {ReturnType<typeof createShootout> | null} */
let shootout = null;
/** @type {ReturnType<typeof createScrimmage> | null} */
let scrimmage = null;
/** @type {ReturnType<typeof buildPuckMesh> | null} */
let puckView = null;
/** @type {(() => void) | null} */
let removeSubstepSync = null;
let playerDriving = false;
let lastHitSeen = -1;
function clearMode() {
if (removeSubstepSync) {
removeSubstepSync();
removeSubstepSync = null;
}
if (shootout) {
shootout.destroy();
shootout = null;
}
if (scrimmage) {
scrimmage.destroy();
scrimmage = null;
}
if (puckView) {
// Ring is parented under the mesh.
scene.remove(puckView.mesh);
puckView.mesh.geometry?.dispose?.();
puckView.mesh.material?.dispose?.();
puckView.ring.geometry?.dispose?.();
puckView.ring.material?.dispose?.();
puckView = null;
}
if (match) {
match.destroy();
match = null;
}
mode = null;
playerDriving = false;
lastHitSeen = -1;
hud.textContent = '';
Object.assign(cam.state, {
mode: 'broadcast',
followIndex: 0,
distance: 34,
pitch: 0.62,
});
}
/**
* Take control of a skater, or give them back.
*
* Taking control snaps the camera onto whoever you just grabbed — driving a
* skater you cannot see is the kind of thing that reads as a broken build.
*/
let playerShooting = false;
/**
* Take the shooter, or hand them back. In a shootout there is only one
* skater worth driving, and which one it is changes every attempt — so
* control follows the shooter rather than being pinned to an index.
* 1v1: control follows whoever is shooting this attempt.
* 3v3: control follows the camera's follow target (or Home 1).
*/
function toggleControl() {
playerShooting = !playerShooting;
shootout.setShooterControl(playerShooting ? stick : null);
if (playerShooting) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
if (!match) return;
playerDriving = !playerDriving;
if (mode === '1v1' && shootout) {
shootout.setShooterControl(playerDriving ? stick : null);
if (playerDriving) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
return;
}
// 3v3 scrimmage
if (!playerDriving) {
if (match.playerIndex !== null) match.setControl(match.playerIndex, null);
return;
}
const idx = cam.state.mode === 'follow'
? cam.state.followIndex
: 0;
match.setControl(idx, stick);
cam.state.mode = 'follow';
cam.state.followIndex = idx;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
function showMenu() {
clearMode();
menuEl.hidden = false;
menuEl.querySelector('button.choice')?.focus();
}
/**
* Start a mode. Safe to call again — tears down whatever was running.
* @param {GameMode} next
*/
function startMode(next) {
if (next !== '1v1' && next !== '3v3') {
throw new Error(`unknown mode: ${next}`);
}
clearMode();
menuEl.hidden = true;
mode = next;
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
puckView = buildPuckMesh(scene, PUCK);
if (next === '1v1') {
// The shootout owns the nets and the goalies, and drives its own
// kinematic bodies inside the physics substep.
shootout = createShootout({ scene, physics, match });
removeSubstepSync = match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
playerDriving = false;
} else {
// Full ice: nets + goalies at both ends, continuous play with scoring.
scrimmage = createScrimmage({ scene, physics, match });
removeSubstepSync = match.addSubstepSync((fixedDt) => {
scrimmage.goalies[1].syncPhysics(fixedDt);
scrimmage.goalies[-1].syncPhysics(fixedDt);
});
scrimmage.reset();
// Scrimmage opens under AI; press P to jump in.
playerDriving = false;
}
Object.assign(cam.state, {
mode: 'broadcast',
distance: next === '1v1' ? 34 : 40,
pitch: next === '1v1' ? 0.62 : 0.72,
followIndex: 0,
});
}
// ---- menu wiring --------------------------------------------------------
menuEl.addEventListener('click', (e) => {
const btn = e.target.closest('button.choice');
if (!btn) return;
startMode(/** @type {GameMode} */ (btn.dataset.mode));
});
window.addEventListener('keydown', (e) => {
if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length);
if (e.key === 'r' || e.key === 'R') shootout.reset();
// Menu: 1 / 2 / Enter on focused choice.
if (!menuEl.hidden) {
if (e.key === '1') {
e.preventDefault();
startMode('1v1');
} else if (e.key === '2') {
e.preventDefault();
startMode('3v3');
} else if (e.key === 'Enter') {
const active = document.activeElement;
if (active?.dataset?.mode) {
e.preventDefault();
startMode(/** @type {GameMode} */ (active.dataset.mode));
}
}
return;
}
// In-game.
if (e.key === 'Escape') {
e.preventDefault();
showMenu();
return;
}
if (!match) return;
if (e.key === 'c' || e.key === 'C') {
cam.cycleMode(match.skaters.length);
// If the player is driving in 3v3, keep control on whoever the camera
// is following so C can swap bodies.
if (mode === '3v3' && playerDriving && cam.state.mode === 'follow') {
const prev = match.playerIndex;
if (prev !== null && prev !== cam.state.followIndex) {
match.setControl(prev, null);
match.setControl(cam.state.followIndex, stick);
}
}
}
if (e.key === 'r' || e.key === 'R') {
if (mode === '1v1' && shootout) shootout.reset();
else if (mode === '3v3' && scrimmage) scrimmage.reset();
else match.reset();
}
if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') {
e.preventDefault();
toggleControl();
@@ -153,10 +318,29 @@ async function boot3() {
// Debug handle. The capture tool drives the camera through this to frame
// repeatable shots, and it is the fastest way to poke at a skater from the
// console while tuning.
window.tilt = { match, shootout, cam, physics, scene, renderer, stats, input, toggleControl };
window.tilt = {
get match() { return match; },
get shootout() { return shootout; },
get scrimmage() { return scrimmage; },
get mode() { return mode; },
cam,
physics,
scene,
renderer,
stats,
input,
startMode,
showMenu,
toggleControl,
};
boot.remove();
// Deep link / capture: ?mode=1v1|3v3 skips the menu.
const auto = parseModeFromUrl();
if (auto) startMode(auto);
else showMenu();
let last = performance.now();
let fpsAccum = 0;
let fpsFrames = 0;
@@ -167,61 +351,70 @@ async function boot3() {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
// The camera yaw rides along with the stick so the match can turn a
// screen-space push into a world direction. Sampled before the update so
// input and simulation are one frame consistent.
input.read(dt);
stick.cameraYaw = cam.state.yaw;
match.update(dt);
shootout.update(dt);
// Follow whoever is shooting, so the camera never has to be told.
if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter;
if (match) {
match.update(dt);
if (shootout) shootout.update(dt);
if (scrimmage) scrimmage.update(dt);
// Haptics for anything the player was part of.
const newest = match.recentHits[0];
if (newest && newest.at !== lastHitSeen) {
lastHitSeen = newest.at;
const me = match.playerIndex;
if (me !== null && (newest.attacker === me || newest.victim === me)) {
const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump;
// Taking a hit shakes harder than landing one.
const k = newest.victim === me ? 1 : 0.7;
input.rumble(strong * k, weak * k, ms);
// Follow whoever is shooting, so the camera never has to be told.
if (mode === '1v1' && shootout && cam.state.mode === 'follow') {
cam.state.followIndex = shootout.state.shooter;
}
// Haptics for anything the player was part of.
const newest = match.recentHits[0];
if (newest && newest.at !== lastHitSeen) {
lastHitSeen = newest.at;
const me = match.playerIndex;
if (me !== null && (newest.attacker === me || newest.victim === me)) {
const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump;
// Taking a hit shakes harder than landing one.
const k = newest.victim === me ? 1 : 0.7;
input.rumble(strong * k, weak * k, ms);
}
}
if (puckView) {
puckView.mesh.position.copy(match.puck.position());
puckView.mesh.quaternion.copy(match.puck.rotation());
puckView.ring.visible = match.possession.loose;
}
cam.update(dt, match.states);
fpsAccum += dt;
fpsFrames++;
if (fpsAccum >= 0.5) {
stats.fps = Math.round(fpsFrames / fpsAccum);
stats.steps = physics.stepCount;
stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0);
fpsAccum = 0;
fpsFrames = 0;
}
drawHud();
} else {
// Idle rink under the menu — slow orbit so the ice is not a still photo.
cam.state.yaw += dt * 0.08;
cam.update(dt, []);
hud.textContent = '';
}
puckView.mesh.position.copy(match.puck.position());
puckView.mesh.quaternion.copy(match.puck.rotation());
puckView.ring.visible = match.possession.loose;
cam.update(dt, match.states);
renderer.render(scene, cam.camera);
fpsAccum += dt;
fpsFrames++;
if (fpsAccum >= 0.5) {
stats.fps = Math.round(fpsFrames / fpsAccum);
stats.steps = physics.stepCount;
stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0);
fpsAccum = 0;
fpsFrames = 0;
}
// Drawn every frame, not on the half-second tick: the hustle and shot
// meters are feedback, and feedback at 2 Hz is worse than none.
drawHud();
requestAnimationFrame(frame);
}
const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
function drawHud() {
if (!match) return;
const watching = cam.state.mode === 'follow'
? match.states[cam.state.followIndex]?.name ?? 'broadcast'
: 'broadcast';
const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
const down = match.skaters.filter((s) => s.limp).length;
const feed = match.recentHits
.filter((h) => h.outcome !== 'bump')
.slice(0, 3)
@@ -232,44 +425,78 @@ async function boot3() {
? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
: 'pad: none — keyboard';
const so = shootout.state;
const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY');
const scoreLine = `${teamName(0)} ${so.score[0]}${so.score[1]} ${teamName(1)}`
+ ` round ${so.round}`;
const phaseLine = so.phase === 'ready'
? `${teamName(so.shootingTeam)} to shoot…`
: so.phase === 'result'
? (so.last?.result === 'goal'
? `GOAL — ${teamName(so.last.team)}`
: `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
: `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
const carrier = match.possession.carrier;
const puckLine = carrier === null
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
const mag = match.possession.tuning.magnetism;
hud.textContent = `${scoreLine}`
const controlsHint = player
? (input.connected
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
: '';
if (mode === '1v1' && shootout) {
const so = shootout.state;
const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY');
const scoreLine = `${teamName(0)} ${so.score[0]}${so.score[1]} ${teamName(1)}`
+ ` round ${so.round}`;
const phaseLine = so.phase === 'ready'
? `${teamName(so.shootingTeam)} to shoot…`
: so.phase === 'result'
? (so.last?.result === 'goal'
? `GOAL — ${teamName(so.last.team)}`
: `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
: `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
hud.textContent = `1-on-1 shootout`
+ `\n${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerDriving ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart [Esc] menu`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ controlsHint
+ (feed ? `\n\nhits:\n${feed}` : '');
return;
}
// 3v3 with nets and goalies
const sc = scrimmage?.state;
const scoreLine = sc
? `HOME ${sc.score[0]}${sc.score[1]} AWAY`
: 'HOME 0 — 0 AWAY';
let phaseLine = 'live';
if (sc?.phase === 'goal') {
const who = sc.last?.team === 0 ? 'HOME' : 'AWAY';
phaseLine = `GOAL — ${who}`;
} else if (sc?.phase === 'cover') {
phaseLine = `covered (${sc.last?.detail ?? 'goalie'})`;
} else if (sc?.phase === 'oob') {
phaseLine = `whistle — puck ${sc.last?.detail || 'out'} · faceoff ${sc.last?.faceoff?.id ?? ''}`;
} else if (sc?.phase === 'skater_oob') {
phaseLine = `whistle — ${sc.last?.detail || 'skater'} over boards · faceoff ${sc.last?.faceoff?.id ?? ''}`;
}
hud.textContent = `3-on-3 · cam ${watching}`
+ `\n${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerShooting ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart`
+ `\n[P] ${playerDriving ? 'hand back to AI' : 'take control'} [C] camera [R] faceoff [Esc] menu`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ (player
? (input.connected
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
: '')
+ controlsHint
+ (feed ? `\n\nhits:\n${feed}` : '');
}
requestAnimationFrame(frame);
}
boot3().catch((err) => {
bootApp().catch((err) => {
console.error(err);
boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
});
+26 -1
View File
@@ -1,4 +1,8 @@
import { RINK, clampToRink, insideRink, randomIcePoint, rinkOutline, rinkPenetration } from '../shared/rink.js';
import {
FACEOFF_DOTS, MARKINGS, RINK,
clampToRink, insideRink, nearestFaceoffDot, puckPlayable,
randomIcePoint, rinkOutline, rinkPenetration,
} from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
section('penetration on the straights');
@@ -74,4 +78,25 @@ section('random points land on the ice');
}
}
section('faceoff dots');
{
ok(FACEOFF_DOTS.length === 9, 'nine faceoff dots');
ok(nearestFaceoffDot(0, 0).id === 'centre', 'centre ice → centre dot');
ok(nearestFaceoffDot(MARKINGS.zoneDotX, MARKINGS.faceoffDotZ).id === 'ez-pp', 'end-zone corner maps to itself');
const nearSide = nearestFaceoffDot(RINK.halfX + 2, MARKINGS.faceoffDotZ * 0.9);
ok(nearSide.id.startsWith('ez-p'), `puck past +X boards nearest end-zone (+ got ${nearSide.id})`);
const mid = nearestFaceoffDot(1, MARKINGS.faceoffDotZ);
ok(mid.id === 'nz-pp' || mid.id === 'centre', `near neutral +Z maps sensibly (${mid.id})`);
}
section('puck playable');
{
ok(puckPlayable(0, 0.02, 0).ok, 'centre ice is playable');
ok(!puckPlayable(RINK.halfX + 1, 0.02, 0).ok, 'past the end boards is dead');
ok(!puckPlayable(0, 3.5, 0).ok, 'over the glass is dead');
ok(!puckPlayable(0, -0.5, 0).ok, 'under the ice is dead');
ok(puckPlayable(0, 0.5, 0).ok, 'a lifted puck still on the ice plane is live');
}
done('rink');
+6 -1
View File
@@ -83,10 +83,15 @@ try {
});
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
await page.goto(URL, { waitUntil: 'domcontentloaded' });
// 3v3 scrimmage is what the lineup / skating shots need; skip the menu.
const captureUrl = new URL(URL);
if (!captureUrl.searchParams.has('mode')) captureUrl.searchParams.set('mode', '3v3');
await page.goto(captureUrl.href, { waitUntil: 'domcontentloaded' });
// The boot overlay is removed once physics is up and the first frame ran.
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
// Mode auto-starts from ?mode=; wait until the match handle is live.
await page.waitForFunction(() => window.tilt?.match, { timeout: 15000 });
// The canvas must fill the window exactly, at whatever pixel ratio. Checked
// at two window sizes so a resize path that only works on first load fails
+8
View File
@@ -1,12 +1,20 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 5174, open: true },
// Multi-page: main game + character studio both ship as real HTML entries.
build: {
target: 'es2022',
// The body/skin generators are one deterministic chunk; splitting them buys
// nothing and costs a round trip before anything can render.
chunkSizeWarningLimit: 2500,
rollupOptions: {
input: {
main: resolve(import.meta.dirname, 'index.html'),
character: resolve(import.meta.dirname, 'character.html'),
},
},
},
// box3d.js ships an Emscripten bundle that resolves its .wasm via
// import.meta.url. Pre-bundling rewrites that URL and breaks the lookup, so
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
// tilt — static front-end on Workers (assets only, no server Worker code).
// Build first: `npm run build` → uploads `dist/`.
"name": "tilt",
"compatibility_date": "2026-08-03",
"assets": {
"directory": "./dist",
// Multi-page site (index.html + character.html), not a client-side router SPA.
// Missing paths return 404 rather than rewriting everything to index.html.
"not_found_handling": "404-page",
"html_handling": "auto-trailing-slash"
},
"observability": {
"enabled": true
}
}