docs: add CLAUDE.md, update README, add collision fix spec

Add CLAUDE.md with architecture overview and dev commands for future
Claude Code sessions. Update README with tech stack, project structure,
and how-to-play instructions. Add spec for fixing collision physics
tuning issues.
This commit is contained in:
2026-04-12 10:48:50 +07:00
parent fbec9c89fd
commit c0fe135ff0
3 changed files with 347 additions and 2 deletions

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # Start Vite dev server (http://localhost:5173)
npm run build # Production build to dist/
npm test # Run all tests once
npm run test:watch # Run tests in watch mode
npx vitest run src/fruits.test.js # Run a single test file
```
No vite.config or vitest.config files — both use defaults.
## Architecture
This is a Suika Game (watermelon merge puzzle) built with **vanilla JavaScript + Matter.js + Canvas2D**, bundled by Vite.
### Module Orchestration
`game.js` is the central orchestrator. The Game class owns the physics engine and game state, delegating to specialized modules:
- **physics.js** — Thin wrapper around Matter.js. Creates engine, walls, fruit bodies. Bodies are annotated with custom properties: `fruitTier` (0-10), `removing` (merge flag), `dropTime` (grace period timestamp).
- **merger.js** — Listens to Matter.js `collisionStart` events. Queues merges into a pending array; `flushMerges()` is called by the game loop *after* `Engine.update()` to avoid mid-step world mutation. Receives `addScore()` callback to stay decoupled from game state.
- **renderer.js** — Pure rendering: `render(ctx, bodies, state)` draws everything to Canvas2D with no side effects.
- **input.js** — Mouse/touch event binding + `clampX()` utility that accounts for fruit radius to keep drops within container walls.
- **fruits.js** — Static array of 11 fruit tiers. Only tiers 0-4 are randomly selected for dropping.
- **constants.js** — Single source of truth for all dimensions, physics tuning, and timing values.
### Key Patterns
- **Deferred merge processing**: Collisions are queued during physics events, then flushed after the engine step completes. This prevents Matter.js internal state corruption.
- **Grace period**: Newly dropped fruits have 1000ms immunity from game-over detection to prevent instant-loss on drops near the danger line.
- **State via callbacks**: `setupMergeHandler()` receives `getState()` and `addScore()` callbacks rather than importing game state directly.
## Testing
Tests are colocated (`src/*.test.js`) using Vitest with `describe/it/expect`. No mocking — tests use real Matter.js engine instances and pure function assertions. Physics integration tests step the engine 10-60 times and assert state changes.

View File

@@ -1,2 +1,61 @@
# try-claudekit # Suika Game (Watermelon Game)
Try to make a [Suika game](https://en.wikipedia.org/wiki/Suika_Game) using [claudekit](https://github.com/carlrannaberg/claudekit)
A browser-based clone of the [Suika Game](https://en.wikipedia.org/wiki/Suika_Game) — a physics-based puzzle game where you drop fruits into a container. When two identical fruits collide, they merge into the next larger fruit. The goal is to create a watermelon and get the highest score.
Built with [ClaudeKit](https://github.com/carlrannaberg/claudekit).
## Tech Stack
| Category | Tool | Version | Purpose |
|----------|------|---------|---------|
| **Runtime** | [Matter.js](https://brm.io/matter-js/) | ^0.20.0 | 2D rigid-body physics engine |
| **Rendering** | Canvas2D | (built-in) | Fruit drawing, UI overlay, game-over screen |
| **Build** | [Vite](https://vitejs.dev/) | ^8.0.4 | Dev server, ES module bundling, production builds |
| **Testing** | [Vitest](https://vitest.dev/) | ^4.1.4 | Unit and integration tests |
| **Language** | JavaScript (ES Modules) | — | Vanilla JS, no framework |
| **Dev Tooling** | [ClaudeKit](https://github.com/carlrannaberg/claudekit) | — | Git hooks for linting, type-checking, testing, self-review |
## Project Structure
```
src/
main.js Entry point: initializes game, attaches event listeners
game.js Game class: orchestrates engine, renderer, input, state
physics.js Matter.js engine setup, world configuration, walls
renderer.js Canvas2D rendering: fruits, walls, UI overlay
input.js Mouse/touch input handling, drop control
fruits.js Fruit definitions (sizes, colors, labels, progression)
merger.js Collision detection callback, merge logic, scoring
constants.js Game dimensions, physics tuning, timing constants
style.css Page styling
*.test.js Vitest test files
index.html Single HTML page with canvas element
specs/ Feature and bugfix specifications
```
## Getting Started
```bash
npm install
npm run dev
```
Open the URL shown in the terminal (default: http://localhost:5173).
## How to Play
1. Move your mouse (or touch on mobile) to position the fruit
2. Click/tap to drop it into the container
3. When two identical fruits touch, they merge into the next larger fruit
4. The fruit chain goes: Cherry → Strawberry → Grapes → Dekopon → Persimmon → Apple → Pear → Peach → Pineapple → Melon → Watermelon
5. Game ends when fruits stack above the danger line
## Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Start Vite dev server with HMR |
| `npm run build` | Production build to `dist/` |
| `npm run preview` | Preview the production build |
| `npm test` | Run all tests once |
| `npm run test:watch` | Run tests in watch mode |

View File

@@ -0,0 +1,246 @@
# Fix Collision Behavior and Physics Tuning
**Status**: Draft
**Authors**: Claude Code, 2026-04-12
**Type**: Bugfix / Improvement
---
## Overview
Fix the "weird collision" behavior in the Suika Game by addressing physics tuning issues and improving merge execution safety. The current implementation has unstable collision feel due to low density values, missing delta clamping, and a fragile merge removal pattern that can cause ghost bodies and unexpected cascading merges.
## Background / Problem Statement
The current game uses raw Matter.js (v0.20) with Canvas2D rendering. Players report that collisions feel "weird" — fruits appear floaty, merges can glitch, and physics behavior is inconsistent. Investigation reveals several root causes:
1. **Physics tuning**: Uniform density of 0.001 makes all fruits unrealistically light. Large fruits don't feel heavier than small ones.
2. **Merge timing**: Bodies flagged for removal (`removing: true`) remain physically active until `flushMerges()` runs. During that window, they can collide with other fruits, potentially triggering unintended merges.
3. **Frame delta unclamped**: Lag spikes can cause huge physics steps where bodies tunnel through walls or each other.
4. **No scaled density**: All fruits share the same density regardless of tier, so a Watermelon (radius 77) doesn't feel meaningfully heavier than a Cherry (radius 12) beyond area-based mass.
### Why Not Phaser?
Research confirms that Phaser 3 (v3.90.0) wraps Matter.js without adding deferred-removal or safer collision handling. The `collisionstart` event in Phaser has the exact same mid-step mutation risk. Phaser adds ~345 KB gzipped for features (sprite management, scene system, asset loading) that this game doesn't need. Fixing the issues in the current codebase is more appropriate.
## Goals
- Make fruit collisions feel physically satisfying and stable
- Eliminate ghost bodies and double-merge glitches
- Ensure larger fruits feel heavier and more impactful
- Prevent frame-spike physics tunneling
- Keep the existing vanilla JS + Matter.js architecture
## Non-Goals
- Migrating to Phaser or another game framework
- Adding visual effects (particles, screen shake) for merges
- Changing the fruit progression chain or scoring system
- Adding sound effects
## Technical Dependencies
| Dependency | Version | Purpose |
|---|---|---|
| [Matter.js](https://brm.io/matter-js/) | ^0.20.0 | 2D physics engine (already installed) |
No new dependencies required.
## Detailed Design
### 1. Tier-Scaled Density
Replace the uniform density with per-tier density scaling. Larger fruits should be denser, making them settle more firmly and push smaller fruits aside.
**File**: `src/constants.js`
```javascript
// Replace flat density with base + scaling
export const BASE_DENSITY = 0.002;
export const DENSITY_SCALE_PER_TIER = 0.0005;
```
**File**: `src/physics.js``createFruitBody()`
```javascript
export function createFruitBody(tier, x, y) {
const fruit = FRUITS[tier];
const density = BASE_DENSITY + DENSITY_SCALE_PER_TIER * tier;
const body = Bodies.circle(x, y, fruit.radius, {
...FRUIT_BODY_OPTIONS,
density,
label: `fruit_${tier}`,
});
body.fruitTier = tier;
body.removing = false;
body.dropTime = Date.now();
return body;
}
```
This gives Cherry ~0.002 density and Watermelon ~0.007 — a 3.5x density increase on top of the much larger area, making watermelons feel substantially heavier.
### 2. Improved Physics Constants
**File**: `src/constants.js`
```javascript
export const GRAVITY = { x: 0, y: 1.8 }; // Slightly stronger gravity
export const FRUIT_BODY_OPTIONS = {
restitution: 0.3, // Slightly bouncier for more satisfying collisions
friction: 0.3, // Lower friction so fruits slide and settle naturally
frictionAir: 0.02, // More air drag to dampen chaotic bouncing
frictionStatic: 0.5, // Higher static friction so stacked fruits don't slide
};
```
Key changes:
- **restitution 0.2 → 0.3**: More responsive bounce on contact
- **friction 0.5 → 0.3**: Fruits slide into gaps more naturally
- **frictionAir 0.01 → 0.02**: Reduces floaty drifting
- **frictionStatic (new) 0.5**: Prevents settled stacks from sliding
- **gravity 1.5 → 1.8**: Faster settling, feels more weighty
### 3. Delta Time Clamping
**File**: `src/game.js``loop()`
```javascript
loop(time) {
const rawDelta = time - this.lastTime;
const delta = Math.min(rawDelta, 20); // Cap at ~50fps equivalent
this.lastTime = time;
// ...
}
```
This prevents lag spikes from causing physics tunneling. At 20ms max, the physics engine never advances more than one "normal" frame's worth of simulation per step.
### 4. Safer Merge Execution with Collision Filtering
The current approach already defers removal to `flushMerges()` after `stepEngine()`, which is correct. The remaining issue is that bodies flagged `removing: true` can still physically collide with other bodies during the same frame.
**File**: `src/merger.js` — In the collision handler, after flagging bodies:
```javascript
Events.on(engine, 'collisionStart', (event) => {
for (const pair of event.pairs) {
const { bodyA, bodyB } = pair;
if (
bodyA.fruitTier !== undefined &&
bodyB.fruitTier !== undefined &&
bodyA.label === bodyB.label &&
!bodyA.removing &&
!bodyB.removing
) {
bodyA.removing = true;
bodyB.removing = true;
// Make bodies sensors so they stop physically interacting
bodyA.isSensor = true;
bodyB.isSensor = true;
pendingMerges.push({ bodyA, bodyB, tier: bodyA.fruitTier });
}
}
});
```
Setting `isSensor = true` on flagged bodies means Matter.js will still detect them for collision callbacks but won't apply physical forces to/from them. This prevents ghost collisions between a merging body and its neighbors.
### 5. Renderer: Skip Sensor Bodies
**File**: `src/renderer.js``drawFruits()`
The renderer already skips bodies with `body.removing === true`, so no change needed. The sensor flag is purely for physics behavior.
### Architecture Summary
```
No new files. Changes to existing modules:
src/constants.js — Updated physics values, add density scaling constants
src/physics.js — Per-tier density in createFruitBody()
src/game.js — Delta clamping in loop()
src/merger.js — Set isSensor on merging bodies
```
## User Experience
- Fruits drop faster and settle more quickly (stronger gravity)
- Large fruits feel heavier and push small fruits aside
- Collisions have slightly more bounce, feeling more responsive
- Merges are cleaner — no "phantom" collisions from disappearing fruits
- Stacked fruits stay stable instead of slowly sliding apart
- Lag spikes no longer cause fruits to teleport through walls
## Testing Strategy
### Unit Tests
- **`constants.test.js`**: Verify BASE_DENSITY and DENSITY_SCALE_PER_TIER produce reasonable values for all 11 tiers (density always positive, monotonically increasing)
- **`physics.test.js`** (update existing): Verify `createFruitBody()` applies tier-scaled density. Test that tier-0 density < tier-10 density.
### Integration Tests
- **Delta clamping test**: Simulate a 100ms delta, verify the engine only advances by 20ms worth of physics
- **Sensor flag test**: Create two same-tier bodies, trigger merge detection, verify both bodies have `isSensor === true` after collision
- **No cascade merge test**: After flagging bodies as removing/sensor, verify they don't trigger additional merge events with neighboring bodies
### Manual Testing
- Play through a full game and verify:
- Fruits no longer feel floaty
- Large fruits settle firmly
- Merges are clean without visual glitches
- No fruits escape the container walls during lag
- Chain merges (merge produces a fruit that immediately merges again) still work correctly
## Performance Considerations
- Tier-scaled density has zero performance cost (one multiplication per fruit creation)
- Setting `isSensor` on merging bodies reduces physics workload (engine skips force resolution for sensors)
- Delta clamping prevents runaway physics computation on lag frames
- No new per-frame allocations or computation added
## Security Considerations
No security implications — all changes are to client-side physics tuning constants and game logic.
## Documentation
No documentation changes needed. The README setup instructions and gameplay description remain the same.
## Implementation Phases
### Phase 1: Core Fixes
1. Update `src/constants.js` with new physics values and density scaling constants
2. Update `src/physics.js` to apply per-tier density in `createFruitBody()`
3. Update `src/game.js` to clamp delta time in `loop()`
4. Update `src/merger.js` to set `isSensor = true` on merging bodies
5. Update existing tests and add new ones
6. Manual playtest to verify feel
### Phase 2: Fine-Tuning (if needed)
1. Adjust gravity, restitution, friction values based on playtesting
2. Consider adding `sleepThreshold` to Matter.js engine to let settled bodies sleep (performance optimization)
3. Tune density scaling curve if large fruits feel too heavy or too light
## Open Questions
1. **Exact density curve**: Linear scaling (`BASE + SCALE * tier`) is proposed. Would exponential scaling feel better for the largest fruits? Needs playtesting.
2. **Sleep threshold**: Should settled fruits use Matter.js sleeping to improve performance with many bodies? This could cause "delayed wakeup" artifacts but would improve stability of stacks.
3. **Restitution per tier**: Should larger fruits be less bouncy (lower restitution) than smaller ones? This would make large fruits feel more "solid" but adds complexity.
## References
- [Matter.js Body Properties](https://brm.io/matter-js/docs/classes/Body.html)
- [Matter.js isSensor documentation](https://brm.io/matter-js/docs/classes/Body.html#property_isSensor)
- [Matter.js Engine.update delta parameter](https://brm.io/matter-js/docs/classes/Engine.html#method_update)
- [Phaser 3 Suika implementation (reference)](https://github.com/Coteh/suika-clone) — Uses same Matter.js deferred-removal pattern
- [TomboFry/suika-game](https://github.com/TomboFry/suika-game) — Vanilla JS + Matter.js reference
- [Original Suika Game spec](./feat-suika-game.md)