20 KiB
Tron.io Rewrite — Plain JS Client + Rust Server
Stack
- Server: Rust,
actix-web(HTTP + WebSocket),serde_jsonfor the wire protocol,uuidfor player IDs,randfor 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:
{ "type": "login", "name": "..." }
{ "type": "logout" }
{ "type": "respawn" }
{ "type": "update", "player": { "angle": 1.23 } }
Server → client:
{ "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:
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-4radians): just updatex2,y2of 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.
- If parallel (within a small epsilon, e.g.
Pseudocode:
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
f32consistently;f64everywhere 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
wallsandopen_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(or2 * playerSize). Smaller cells = more buckets but fewer entries per bucket; pick something close to the player's collision radius. - Grid dimensions:
arenaSize / cell_sizecells per axis.
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.
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.
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 asegments: 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
HashSetfor 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 flatVecindexed bycy * 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.
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
tto[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
sqrtuntil 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
Playerkeeps apending_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.
// 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 updatelastSegmentEndto 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):
// 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:
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:
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 = falseon the main context to avoid blurry trails. - Trail canvas size = arena size. With
arenaSize=1000that'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:
// 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):
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 (thewhileloops above). This was a bug in the originalupdate.js. - Walls are not interpolated: they're static once placed. Only interpolate
x,y,angle. - Clock sync: the server sends
timein ms. The client can't compareDate.now()to it directly because clocks differ. On the firstgameUpdate, recordlocalTimeAtFirstUpdate = Date.now()andserverTimeAtFirstUpdate = 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.
- Echo server — actix-web with
/socketroute, accepts WS, echoes messages. Verify with browser devtools. - Static client serving — actix-files serves
client/index.htmlat/. Verify you see the page. - Settings + protocol + login —
settings.rs,protocol.rs, handleloginmessage, send backlogin+gameSettings. Verify with a minimal client that just logs and console.logs the response. - Player + tick loop + broadcast —
player.rs(point-based, no segments yet),game.rs60 Hz tick, broadcast full state every other tick. Get a single player moving on screen. - Mouse input + rendering — port
render.jsand mouse handling. You should now have a single dot you can steer around the arena. - Collision (brute force) — port the current O(P²×W) check just to get death working.
- Switch walls to segments + collinear collapse — this is the big one. Once done, trails become straight lines with corners. Verify trails look right.
- Spatial hash — replace brute-force collision. Verify collisions still correct with many walls.
- Delta updates — switch from full-state broadcast to
newSegments. Client accumulates. Verify long matches don't slow down. - 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.
- Interpolation — add the two-snapshot buffer + clock sync. Verify smoothness on a throttled network (Chrome devtools "Slow 3G").
- Multiplayer — open two browser tabs. Verify two players can see each other and collide with each other's trails.
- Explosions + sound — port
sprites.js,sound.js. Death plays explosion + sprite. - Menu + death modal + debug overlay + leaderboard — port the UI from
App.vueandGame.vueto plain HTML. - Named-player faces (e/q/r/t) — port
playerFacerendering. - Dockerfile + docker-compose — multi-stage Rust build, copy client in.
- Delete old
client/andserver/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, ort.
Things to drop
- Vue, Vuex, Buefy, Babel, vue-cli, PWA, service worker.
wallsBezier(was already disabled inGame.vue).- The
setInterval(..., 10000000)+ immediateclearIntervalconstructor no-op.
Assumptions to confirm
- WebSocket path stays
/socket(current prod URL iswss://tronio.gltronic.ovh/socket). wallUpdate=3in the old code meant "add a point every 3 ticks". In the new model, the open segment extends every tick while straight; thewallUpdateconcept 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).