Compare commits
1
Commits
ba2199c60b
...
v3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42dfd9eb83
|
+449
@@ -0,0 +1,449 @@
|
|||||||
|
# Tron.io Rewrite — Plain JS Client + Rust Server
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Server:** Rust, `actix-web` (HTTP + WebSocket), `serde_json` for the wire protocol, `uuid` for player IDs, `rand` for colors/spawn.
|
||||||
|
- **Client:** plain HTML/CSS + native ES modules (`<script type="module">`). No bundler, no framework, no PWA.
|
||||||
|
- **Protocol:** JSON, same shape as today. Server-authoritative at 60 Hz, broadcasts every other tick. Client interpolates between the last two snapshots.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
├── Cargo.toml
|
||||||
|
└── src/
|
||||||
|
├── main.rs # actix App: GET /socket -> ws, serve ./client via actix-files
|
||||||
|
├── settings.rs # GameSettings (playerSize=10, playerSpeed=5, wallUpdate=3, arenaSize=1000, wallSize=8)
|
||||||
|
├── wall.rs # Wall { x1,y1,x2,y2, bbox }
|
||||||
|
├── player.rs # Player state + step() + collision helpers
|
||||||
|
├── spatial.rs # SpatialHash over wall segments
|
||||||
|
├── collision.rs # segment-vs-circle check
|
||||||
|
├── game.rs # tick loop, broadcast, delta updates
|
||||||
|
├── protocol.rs # serde enums: ClientMsg, ServerMsg
|
||||||
|
└── session.rs # actix WS session actor
|
||||||
|
|
||||||
|
client/
|
||||||
|
├── index.html # menu + canvas
|
||||||
|
├── style.css
|
||||||
|
├── assets/ # reuse sprite/, sound/, logo, TRON font
|
||||||
|
└── src/
|
||||||
|
├── main.js # entry, wire DOM
|
||||||
|
├── socket.js # WebSocket wrapper
|
||||||
|
├── state.js # plain module (no Vuex)
|
||||||
|
├── interpolate.js # snapshot interpolation
|
||||||
|
├── game.js # rAF loop
|
||||||
|
├── render.js # canvas 2D drawing
|
||||||
|
├── trail_canvas.js # offscreen persistent trail layer
|
||||||
|
├── sprites.js # Explosion
|
||||||
|
└── sound.js # Audio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message protocol (JSON)
|
||||||
|
|
||||||
|
Client → server:
|
||||||
|
```json
|
||||||
|
{ "type": "login", "name": "..." }
|
||||||
|
{ "type": "logout" }
|
||||||
|
{ "type": "respawn" }
|
||||||
|
{ "type": "update", "player": { "angle": 1.23 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Server → client:
|
||||||
|
```json
|
||||||
|
{ "type": "login", "player": { ...full... } }
|
||||||
|
{ "type": "gameSettings", "gameSettings": { ... } }
|
||||||
|
{ "type": "gamePlayerSpawn", "player": { ...full incl. all walls... } }
|
||||||
|
{ "type": "gamePlayerDead", "player": { ... } }
|
||||||
|
{ "type": "gameUpdate", "time": <ms>, "players": [
|
||||||
|
{ "id","name","color","x","y","angle","state","score",
|
||||||
|
"newSegments": [x1,y1,x2,y2, x1,y1,x2,y2, ...] }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`gamePlayerSpawn` carries the full wall list (for joins/respawns). `gameUpdate` carries only the segments added since the previous update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The hard algorithms
|
||||||
|
|
||||||
|
These are the parts worth understanding before you write them. Everything else is straightforward porting.
|
||||||
|
|
||||||
|
### 1. Wall as segment + collinear collapse
|
||||||
|
|
||||||
|
**Why:** Tron trails are mostly straight lines with occasional turns. Storing one point per tick (current code) means thousands of redundant points per straight run. Storing segments collapses that to one segment per straight run — 10–100× less data, smaller JSON, faster collision, faster render.
|
||||||
|
|
||||||
|
**Data model:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Wall {
|
||||||
|
x1: f32, y1: f32, // start
|
||||||
|
x2: f32, y2: f32, // end (mutable — extended each tick while straight)
|
||||||
|
// bbox cached:
|
||||||
|
min_x: f32, min_y: f32, max_x: f32, max_y: f32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The collapse rule** (in `Player::step`):
|
||||||
|
|
||||||
|
- Keep the *current (open) segment* — the one being extended.
|
||||||
|
- Each tick, advance the player by `playerSpeed * (cos angle, sin angle)`.
|
||||||
|
- Compare the new heading to the open segment's heading:
|
||||||
|
- If **parallel** (within a small epsilon, e.g. `1e-4` radians): just update `x2,y2` of the open segment. No new wall added.
|
||||||
|
- If **different** (player turned): finalize the open segment (recompute its bbox), push a new open segment starting at the old end with the new heading.
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn step(&mut self, ticks: u32) {
|
||||||
|
for _ in 0..ticks {
|
||||||
|
self.last_wall += 1;
|
||||||
|
let nx = self.x + speed * cos(self.angle);
|
||||||
|
let ny = self.y + speed * sin(self.angle);
|
||||||
|
|
||||||
|
match self.open_segment {
|
||||||
|
Some(seg) => {
|
||||||
|
let seg_angle = atan2(seg.y2 - seg.y1, seg.x2 - seg.x1);
|
||||||
|
let d = (self.angle - seg_angle).abs().rem_euclid(2*PI);
|
||||||
|
let d = d.min(2*PI - d); // smallest angular difference
|
||||||
|
|
||||||
|
if d < 1e-4 {
|
||||||
|
// extend the open segment
|
||||||
|
seg.x2 = nx; seg.y2 = ny;
|
||||||
|
seg.max_x = seg.max_x.max(nx); seg.max_y = seg.max_y.max(ny);
|
||||||
|
seg.min_x = seg.min_x.min(nx); seg.min_y = seg.min_y.min(ny);
|
||||||
|
} else {
|
||||||
|
// finalize open segment, push new one
|
||||||
|
self.walls.push(seg); // (or keep open as last element — your choice)
|
||||||
|
self.open_segment = Some(Wall::new(self.x, self.y, nx, ny));
|
||||||
|
self.score += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.open_segment = Some(Wall::new(self.x, self.y, nx, ny)),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.x = nx; self.y = ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design choice to make:** either keep the open segment as the last element of `self.walls` (simpler iteration for collision), or as a separate field (cleaner). I'd keep it separate and only push to `walls` on finalize — it makes "don't collide with your own current segment" trivial.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Use `f32` consistently; `f64` everywhere is overkill and slower.
|
||||||
|
- Don't finalize on every tick — only when heading actually changes. That's the whole point.
|
||||||
|
- Reset (respawn) must clear both `walls` *and* `open_segment`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Spatial hash for collision
|
||||||
|
|
||||||
|
**Why:** Current code is `O(players² × walls)` per tick — every player checks every wall of every other player. With 1000 walls and 10 players that's 100k checks per tick. A spatial hash reduces it to `O(players × k)` where `k` is the number of segments in nearby cells (usually <10).
|
||||||
|
|
||||||
|
**Grid:**
|
||||||
|
- Cell size = `playerSize` (or `2 * playerSize`). Smaller cells = more buckets but fewer entries per bucket; pick something close to the player's collision radius.
|
||||||
|
- Grid dimensions: `arenaSize / cell_size` cells per axis.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct SpatialHash {
|
||||||
|
cell_size: f32,
|
||||||
|
cols: i32,
|
||||||
|
rows: i32,
|
||||||
|
buckets: HashMap<(i32, i32), Vec<SegmentId>>, // or Vec<Vec<...>> if arena is fixed-size
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inserting a segment:** rasterize the segment's bbox into the grid — for every cell the bbox overlaps, add the segment ID to that bucket.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn insert(&mut self, seg: &Wall, id: SegmentId) {
|
||||||
|
let (cx1, cy1) = self.cell_of(seg.min_x, seg.min_y);
|
||||||
|
let (cx2, cy2) = self.cell_of(seg.max_x, seg.max_y);
|
||||||
|
for cx in cx1..=cx2 {
|
||||||
|
for cy in cy1..=cy2 {
|
||||||
|
self.buckets.entry((cx, cy)).or_default().push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Querying near a player:** compute the player's cell range (player bbox → cells) and union the buckets.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn query(&self, px: f32, py: f32, radius: f32) -> HashSet<SegmentId> {
|
||||||
|
let (cx1, cy1) = self.cell_of(px - radius, py - radius);
|
||||||
|
let (cx2, cy2) = self.cell_of(px + radius, py + radius);
|
||||||
|
let mut out = HashSet::new();
|
||||||
|
for cx in cx1..=cx2 {
|
||||||
|
for cy in cy1..=cy2 {
|
||||||
|
if let Some(b) = self.buckets.get(&(cx, cy)) {
|
||||||
|
out.extend(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-tick maintenance:**
|
||||||
|
- New finalized segments → `insert`.
|
||||||
|
- On respawn → remove that player's segments from all buckets they were in. Keep a reverse map `player_id -> Vec<SegmentId>` and a `segments: HashMap<SegmentId, Wall>` so removal is cheap.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Don't reinsert the open segment every tick — it changes every tick. Either skip it (don't collide with own current segment anyway) or only reinsert when it's finalized.
|
||||||
|
- Use a `HashSet` for query results to dedupe segments that span multiple cells.
|
||||||
|
- For a 1000×1000 arena with cell_size 10, that's 10k cells — fine as a `HashMap`. If arena grows, switch to a flat `Vec` indexed by `cy * cols + cx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Segment-vs-circle collision
|
||||||
|
|
||||||
|
**Why:** This is the actual collision test once spatial hash narrows the candidates. Player is a circle (radius = `playerSize`); wall is a line segment.
|
||||||
|
|
||||||
|
**Algorithm:** distance from point P to segment AB. If less than radius, collision.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn point_segment_distance(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
|
||||||
|
let abx = bx - ax;
|
||||||
|
let aby = by - ay;
|
||||||
|
let apx = px - ax;
|
||||||
|
let apy = py - ay;
|
||||||
|
let ab_len_sq = abx*abx + aby*aby;
|
||||||
|
|
||||||
|
// t in [0,1] is the projection of P onto line AB
|
||||||
|
let t = if ab_len_sq > 0.0 {
|
||||||
|
((apx * abx + apy * aby) / ab_len_sq).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0 // degenerate segment (A == B)
|
||||||
|
};
|
||||||
|
|
||||||
|
let closest_x = ax + t * abx;
|
||||||
|
let closest_y = ay + t * aby;
|
||||||
|
let dx = px - closest_x;
|
||||||
|
let dy = py - closest_y;
|
||||||
|
(dx*dx + dy*dy).sqrt()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: `if distance < playerSize { collision }`.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Always clamp `t` to `[0,1]` — that's what makes it a *segment* test instead of an *infinite line* test. Forgetting this is a classic bug (player collides with the imaginary extension of a wall).
|
||||||
|
- Avoid `sqrt` until the end: compare squared distance to squared radius, then sqrt only if you need the actual distance. Minor optimization.
|
||||||
|
- Don't test the open segment of the *same* player — they're riding it, of course they're touching it. (You do want to test the previous finalized segment though, to prevent 180° turns into themselves.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Delta wall updates + resync
|
||||||
|
|
||||||
|
**Why:** The current server re-serializes the *entire* wall list of every player on every broadcast. After 2 minutes of play with 5 players that's tens of thousands of points every 33ms — that's the main source of slowdown.
|
||||||
|
|
||||||
|
**Server side (in `game.rs` broadcast):**
|
||||||
|
- Each `Player` keeps a `pending_segments: Vec<Wall>` — segments finalized since the last broadcast.
|
||||||
|
- On broadcast, send each player's `pending_segments` (serialized as a flat `[x1,y1,x2,y2, ...]` array) and clear it.
|
||||||
|
- The currently-open segment is *not* sent (it's still changing). Client renders it locally as a line from its start to the player's interpolated position.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// build the update message
|
||||||
|
for player in players.values() {
|
||||||
|
let new_segments: Vec<f32> = player.pending_segments.iter()
|
||||||
|
.flat_map(|w| [w.x1, w.y1, w.x2, w.y2])
|
||||||
|
.collect();
|
||||||
|
player.pending_segments.clear();
|
||||||
|
// ... pack into update message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client side (in `trail_canvas.js`):**
|
||||||
|
- Maintain `Map<playerId, { offscreenCanvas, lastSegmentEnd }>` per player.
|
||||||
|
- On `gameUpdate`: for each player, stroke each new segment onto their offscreen canvas, then update `lastSegmentEnd` to the last segment's end.
|
||||||
|
- On `gamePlayerSpawn` (join/respawn): clear that player's offscreen canvas, then stroke all walls from the full snapshot.
|
||||||
|
|
||||||
|
**Resync:** late joiners and respawns need the full wall list. Send it in `gamePlayerSpawn`, never in `gameUpdate`. If you ever detect a desync (e.g. client missed an update), the recovery is to request a full snapshot — but with reliable WS messages you generally don't need this.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- The open segment (currently being extended) is rendered client-side as a line from the last finalized point to the player's interpolated position. Don't try to send it incrementally.
|
||||||
|
- When a player dies, their walls stay on the field (so others can still crash into them) but no new segments are added. When they respawn, the old walls are removed from the spatial hash and from the client's offscreen canvas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Offscreen persistent trail canvas
|
||||||
|
|
||||||
|
**Why:** Redrawing the entire polyline every frame is `O(N)` where N grows over the match. With an offscreen canvas, you stroke each segment *once* when it arrives, and per-frame you just blit the canvas — `O(1)` regardless of trail length.
|
||||||
|
|
||||||
|
**Setup (in `trail_canvas.js`):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// one offscreen canvas per player, sized to the arena
|
||||||
|
const trailCanvases = new Map(); // playerId -> { canvas, ctx }
|
||||||
|
|
||||||
|
function getPlayerTrail(playerId) {
|
||||||
|
let t = trailCanvases.get(playerId);
|
||||||
|
if (!t) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = ARENA_SIZE;
|
||||||
|
canvas.height = ARENA_SIZE;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.lineWidth = WALL_SIZE;
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
t = { canvas, ctx };
|
||||||
|
trailCanvases.set(playerId, t);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adding segments:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function addSegments(playerId, color, segments) {
|
||||||
|
const { ctx } = getPlayerTrail(playerId);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < segments.length; i += 4) {
|
||||||
|
ctx.moveTo(segments[i], segments[i+1]);
|
||||||
|
ctx.lineTo(segments[i+2], segments[i+3]);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rendering each frame:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function render(mainCtx, camera, canvasW, canvasH) {
|
||||||
|
for (const [playerId, t] of trailCanvases) {
|
||||||
|
const sx = canvasW/2 - camera.x;
|
||||||
|
const sy = canvasH/2 - camera.y;
|
||||||
|
mainCtx.drawImage(t.canvas, sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The open segment:** draw separately on the main canvas each frame — a line from the last finalized point of each player to their interpolated (x,y).
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Coordinate system: trail canvas is in *world* coordinates (origin = arena top-left). Blit with `drawImage(canvas, canvasW/2 - camera.x, canvasH/2 - camera.y)` to get screen coords.
|
||||||
|
- On respawn: `ctx.clearRect(0, 0, ARENA_SIZE, ARENA_SIZE)` for that player's canvas.
|
||||||
|
- Set `imageSmoothingEnabled = false` on the main context to avoid blurry trails.
|
||||||
|
- Trail canvas size = arena size. With `arenaSize=1000` that's 1MB per player (4 bytes × 1000² × alpha). Fine for ~50 players. If you scale up, switch to a single shared trail canvas and stroke per-player in their color.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Client interpolation between snapshots
|
||||||
|
|
||||||
|
**Why:** The server runs at 60 Hz but network packets jitter. Rendering the latest snapshot directly causes stutter. Buffering two snapshots and interpolating between them by a fixed delay (~100ms behind real time) smooths everything out.
|
||||||
|
|
||||||
|
**The two-snapshot buffer:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// state.js
|
||||||
|
let snapshots = []; // [{ time, players: Map<id, playerData> }, ...]
|
||||||
|
|
||||||
|
function pushSnapshot(time, players) {
|
||||||
|
snapshots.push({ time, players });
|
||||||
|
// keep at most the last 2-3 snapshots
|
||||||
|
while (snapshots.length > 3) snapshots.shift();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interpolation target:** render time = `now - 100ms` (the 100ms is your "interpolation delay"; tune it).
|
||||||
|
|
||||||
|
**Algorithm (in `interpolate.js`):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function interpolate(snapshots, renderTime) {
|
||||||
|
if (snapshots.length === 0) return [];
|
||||||
|
if (snapshots.length === 1) return [...snapshots[0].players.values()];
|
||||||
|
|
||||||
|
// find the two snapshots that bracket renderTime
|
||||||
|
let a = snapshots[0], b = snapshots[1];
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
if (snapshots[i].time > renderTime) {
|
||||||
|
a = snapshots[i-1];
|
||||||
|
b = snapshots[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// if renderTime is after the last snapshot, extrapolate from the last two
|
||||||
|
a = snapshots[snapshots.length - 2];
|
||||||
|
b = snapshots[snapshots.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = b.time - a.time;
|
||||||
|
const t = span > 0 ? (renderTime - a.time) / span : 1.0;
|
||||||
|
const tClamped = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const playerA of a.players.values()) {
|
||||||
|
const playerB = b.players.get(playerA.id);
|
||||||
|
if (!playerB) { out.push(playerA); continue; }
|
||||||
|
out.push({
|
||||||
|
...playerA,
|
||||||
|
x: lerp(playerA.x, playerB.x, tClamped),
|
||||||
|
y: lerp(playerA.y, playerB.y, tClamped),
|
||||||
|
angle: lerpAngle(playerA.angle, playerB.angle, tClamped),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
function lerpAngle(a, b, t) {
|
||||||
|
let d = b - a;
|
||||||
|
while (d > Math.PI) d -= 2 * Math.PI;
|
||||||
|
while (d < -Math.PI) d += 2 * Math.PI;
|
||||||
|
return a + d * t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- **Angle interpolation**: never lerp angles directly — `lerp(350°, 10°, 0.5)` = 180°, which is wrong. Always take the shortest path around the circle (the `while` loops above). This was a bug in the original `update.js`.
|
||||||
|
- **Walls are not interpolated**: they're static once placed. Only interpolate `x`, `y`, `angle`.
|
||||||
|
- **Clock sync**: the server sends `time` in ms. The client can't compare `Date.now()` to it directly because clocks differ. On the first `gameUpdate`, record `localTimeAtFirstUpdate = Date.now()` and `serverTimeAtFirstUpdate = update.time`. From then on, `serverNow = update.time + (Date.now() - localTimeAtFirstUpdate)`. Use that to compute render time.
|
||||||
|
- If the client falls behind (e.g. tab was backgrounded), snapshots may be too old — fall back to rendering the latest snapshot directly until the buffer refills.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested implementation order
|
||||||
|
|
||||||
|
These are ordered so each step produces a runnable, testable state.
|
||||||
|
|
||||||
|
1. **Echo server** — actix-web with `/socket` route, accepts WS, echoes messages. Verify with browser devtools.
|
||||||
|
2. **Static client serving** — actix-files serves `client/index.html` at `/`. Verify you see the page.
|
||||||
|
3. **Settings + protocol + login** — `settings.rs`, `protocol.rs`, handle `login` message, send back `login` + `gameSettings`. Verify with a minimal client that just logs and console.logs the response.
|
||||||
|
4. **Player + tick loop + broadcast** — `player.rs` (point-based, no segments yet), `game.rs` 60 Hz tick, broadcast full state every other tick. Get a single player moving on screen.
|
||||||
|
5. **Mouse input + rendering** — port `render.js` and mouse handling. You should now have a single dot you can steer around the arena.
|
||||||
|
6. **Collision (brute force)** — port the current O(P²×W) check just to get death working.
|
||||||
|
7. **Switch walls to segments + collinear collapse** — this is the big one. Once done, trails become straight lines with corners. Verify trails look right.
|
||||||
|
8. **Spatial hash** — replace brute-force collision. Verify collisions still correct with many walls.
|
||||||
|
9. **Delta updates** — switch from full-state broadcast to `newSegments`. Client accumulates. Verify long matches don't slow down.
|
||||||
|
10. **Offscreen trail canvas** — switch client render from "rebuild polyline every frame" to "stroke on arrival, blit per frame". Verify render is now O(1) per frame.
|
||||||
|
11. **Interpolation** — add the two-snapshot buffer + clock sync. Verify smoothness on a throttled network (Chrome devtools "Slow 3G").
|
||||||
|
12. **Multiplayer** — open two browser tabs. Verify two players can see each other and collide with each other's trails.
|
||||||
|
13. **Explosions + sound** — port `sprites.js`, `sound.js`. Death plays explosion + sprite.
|
||||||
|
14. **Menu + death modal + debug overlay + leaderboard** — port the UI from `App.vue` and `Game.vue` to plain HTML.
|
||||||
|
15. **Named-player faces** (e/q/r/t) — port `playerFace` rendering.
|
||||||
|
16. **Dockerfile + docker-compose** — multi-stage Rust build, copy client in.
|
||||||
|
17. **Delete old `client/` and `server/` TS projects.**
|
||||||
|
|
||||||
|
Stop after step 12 and you have a playable game. Steps 13–15 are polish.
|
||||||
|
|
||||||
|
## Things to carry over from the old code
|
||||||
|
|
||||||
|
- 60 Hz tick, `wallUpdate=3`, `playerSpeed=5`, `arenaSize=1000`, `playerSize=10`, `wallSize=8`.
|
||||||
|
- Mouse-aim angle input (`atan2(dy, dx)` from canvas center).
|
||||||
|
- Camera follows local player, centered (`canvasW/2 - camera.x`, `canvasH/2 - camera.y`).
|
||||||
|
- Random hex color for each player (but use `padStart(6, '0')` — current `.substr(1,6)` can produce malformed colors when the random number is small).
|
||||||
|
- Named-player face rendering for players named exactly `e`, `q`, `r`, or `t`.
|
||||||
|
|
||||||
|
## Things to drop
|
||||||
|
|
||||||
|
- Vue, Vuex, Buefy, Babel, vue-cli, PWA, service worker.
|
||||||
|
- `wallsBezier` (was already disabled in `Game.vue`).
|
||||||
|
- The `setInterval(..., 10000000)` + immediate `clearInterval` constructor no-op.
|
||||||
|
|
||||||
|
## Assumptions to confirm
|
||||||
|
|
||||||
|
- WebSocket path stays `/socket` (current prod URL is `wss://tronio.gltronic.ovh/socket`).
|
||||||
|
- `wallUpdate=3` in the old code meant "add a point every 3 ticks". In the new model, the open segment extends every tick while straight; the `wallUpdate` concept disappears. If you want score to grow at the same rate as before, increment score every 3 ticks while alive rather than on segment finalization.
|
||||||
|
- Named-player faces (`e`/`q`/`r`/`t.png`) are a cosmetic thing for specific player names, not a gameplay mechanic. Keep the name check.
|
||||||
|
- No auth, no rooms, no persistent scoreboard across server restarts (matches current behavior).
|
||||||
Reference in New Issue
Block a user