pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Interpolate remote bodies between samples instead of chasing them
`now.x += (x - now.x) * 0.35` every frame is a different speed at 60Hz than at 144Hz, and it is a pursuit that never arrives: every other player was permanently behind and rubbery. interp.js keeps the last few samples per body with the times they landed and draws the frame at `now - delay`, about one snapshot interval in the past, so a body walks between the two samples that bracket that moment at the speed the server moved it and arrives exactly where the server put it. The delay follows the rate snapshots actually arrive at, so the same code is right at 15Hz and 30Hz. A missed snapshot carries the last pair's velocity for one interval and then holds — never further. Angles turn the short way round, and a body that was out of sight, or that jumped further than anyone can run, starts fresh rather than sliding across the map. Co-Authored-By: Claude Opus 5 <[email protected]>
3 files
changed +327
−18
src/interp.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * Entity interpolation: a pure function of two samples and a time. | |
| 3 | * | |
| 4 | * What is checked here is what a browser cannot be asked about — that the | |
| 5 | * body walks between the pair at the server's own speed, that a missed | |
| 6 | * snapshot is carried exactly one interval and no further, that the turn is | |
| 7 | * taken the short way round, and that a time between the pair never draws | |
| 8 | * the body anywhere but between them. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { arrive, bodyAt, delay, feed, GONE, LAG, LAG_MAX, LAG_MIN, makeTimeline, sample } from "../static/interp.js"; | |
| 13 | ||
| 14 | type Mark = { t: number; x: number; y: number; angle: number }; | |
| 15 | const pair = (a: Mark, b: Mark) => [a, b]; | |
| 16 | ||
| 17 | const walk = pair({ t: 10, x: 100, y: 200, angle: 0 }, { t: 10.1, x: 120, y: 200, angle: 1 }); | |
| 18 | ||
| 19 | test("between the pair it walks at the speed the server moved it", () => { | |
| 20 | expect(sample(walk, 10)).toMatchObject({ x: 100, y: 200 }); | |
| 21 | expect(sample(walk, 10.05).x).toBeCloseTo(110, 6); | |
| 22 | expect(sample(walk, 10.1)).toMatchObject({ x: 120, y: 200 }); | |
| 23 | // Constant speed: equal steps of time are equal steps of distance, which | |
| 24 | // is the whole difference from an exponential chase that never arrives. | |
| 25 | const a = sample(walk, 10.02).x; | |
| 26 | const b = sample(walk, 10.04).x; | |
| 27 | const c = sample(walk, 10.06).x; | |
| 28 | expect(b - a).toBeCloseTo(c - b, 6); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a time inside the pair is never a position outside it", () => { | |
| 32 | const diagonal = pair({ t: 1, x: -30, y: 80, angle: 2 }, { t: 1.07, x: 45, y: -10, angle: -2 }); | |
| 33 | for (let when = 1; when <= 1.07; when += 0.002) { | |
| 34 | const at = sample(diagonal, when); | |
| 35 | expect(at.x).toBeGreaterThanOrEqual(-30); | |
| 36 | expect(at.x).toBeLessThanOrEqual(45); | |
| 37 | expect(at.y).toBeGreaterThanOrEqual(-10); | |
| 38 | expect(at.y).toBeLessThanOrEqual(80); | |
| 39 | } | |
| 40 | }); | |
| 41 | ||
| 42 | test("before the older sample it holds rather than running backwards", () => { | |
| 43 | expect(sample(walk, 9.5)).toMatchObject({ x: 100, y: 200 }); | |
| 44 | expect(sample(walk, 0)).toMatchObject({ x: 100, y: 200 }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a missed snapshot is carried one interval and then held", () => { | |
| 48 | // Half an interval late: half an interval of the pair's own velocity. | |
| 49 | expect(sample(walk, 10.15).x).toBeCloseTo(130, 6); | |
| 50 | // A whole one: exactly one more interval of it. | |
| 51 | expect(sample(walk, 10.2).x).toBeCloseTo(140, 6); | |
| 52 | // And never a step further, however long the silence lasts. | |
| 53 | expect(sample(walk, 10.3).x).toBeCloseTo(140, 6); | |
| 54 | expect(sample(walk, 30).x).toBeCloseTo(140, 6); | |
| 55 | expect(sample(walk, 30).angle).toBeCloseTo(2, 6); | |
| 56 | }); | |
| 57 | ||
| 58 | test("two samples at one instant are one sample", () => { | |
| 59 | const still = pair({ t: 5, x: 7, y: 8, angle: 0.5 }, { t: 5, x: 7, y: 8, angle: 0.5 }); | |
| 60 | expect(sample(still, 9)).toMatchObject({ x: 7, y: 8, angle: 0.5 }); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the turn is taken the short way round", () => { | |
| 64 | const spin = pair({ t: 0, x: 0, y: 0, angle: 3.0 }, { t: 0.1, x: 0, y: 0, angle: -3.0 }); | |
| 65 | // Not back through zero: onward past π, by a sixth of a radian or so. | |
| 66 | expect(sample(spin, 0.05).angle).toBeCloseTo(3.0 + 0.2831853 / 2, 6); | |
| 67 | const back = pair({ t: 0, x: 0, y: 0, angle: -3.0 }, { t: 0.1, x: 0, y: 0, angle: 3.0 }); | |
| 68 | expect(sample(back, 0.05).angle).toBeCloseTo(-3.0 - 0.2831853 / 2, 6); | |
| 69 | }); | |
| 70 | ||
| 71 | // ------------------------------------------------------------- the timeline | |
| 72 | ||
| 73 | const body = (id: string, x: number, y = 0, angle = 0) => ({ id, x, y, angle }); | |
| 74 | ||
| 75 | test("a body is drawn where the server says until there are two of it", () => { | |
| 76 | const timeline = makeTimeline(); | |
| 77 | const first = body("a", 50); | |
| 78 | expect(bodyAt(timeline, first, 10)).toBe(first); | |
| 79 | feed(timeline, "a", first, 10); | |
| 80 | expect(bodyAt(timeline, first, 10.2)).toMatchObject({ x: 50, y: 0 }); | |
| 81 | feed(timeline, "a", body("a", 60), 10.1); | |
| 82 | expect(bodyAt(timeline, body("a", 60), 10.05).x).toBeCloseTo(55, 6); | |
| 83 | }); | |
| 84 | ||
| 85 | test("each body keeps its own answer: one can be held while another is asked", () => { | |
| 86 | const timeline = makeTimeline(); | |
| 87 | feed(timeline, "a", body("a", 0), 1); | |
| 88 | feed(timeline, "a", body("a", 10), 1.1); | |
| 89 | feed(timeline, "b", body("b", 100), 1); | |
| 90 | feed(timeline, "b", body("b", 110), 1.1); | |
| 91 | const a = bodyAt(timeline, body("a", 10), 1.05); | |
| 92 | const b = bodyAt(timeline, body("b", 110), 1.05); | |
| 93 | expect(a.x).toBeCloseTo(5, 6); | |
| 94 | expect(b.x).toBeCloseTo(105, 6); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a body that was out of sight starts again rather than sliding back", () => { | |
| 98 | const timeline = makeTimeline(); | |
| 99 | feed(timeline, "a", body("a", 0), 1); | |
| 100 | feed(timeline, "a", body("a", 10), 1.1); | |
| 101 | feed(timeline, "a", body("a", 900), 1.1 + GONE + 0.1); | |
| 102 | expect(bodyAt(timeline, body("a", 900), 1.1 + GONE + 0.15).x).toBeCloseTo(900, 6); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a jump no one could have run is a respawn, not a walk", () => { | |
| 106 | const timeline = makeTimeline(); | |
| 107 | feed(timeline, "a", body("a", 0), 1); | |
| 108 | feed(timeline, "a", body("a", 10), 1.1); | |
| 109 | // A third of a second's running is 80-odd units; 600 is a teleport. | |
| 110 | feed(timeline, "a", body("a", 610), 1.2); | |
| 111 | expect(bodyAt(timeline, body("a", 610), 1.25).x).toBeCloseTo(610, 6); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a sample that arrives out of order is dropped", () => { | |
| 115 | const timeline = makeTimeline(); | |
| 116 | feed(timeline, "a", body("a", 0), 1); | |
| 117 | feed(timeline, "a", body("a", 10), 1.1); | |
| 118 | feed(timeline, "a", body("a", 999), 1.05); | |
| 119 | expect(bodyAt(timeline, body("a", 10), 1.1).x).toBeCloseTo(10, 6); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the delay follows the rate the snapshots actually arrive at", () => { | |
| 123 | const slow = makeTimeline(); | |
| 124 | for (let i = 0; i < 60; i++) arrive(slow, 10 + i / 15); | |
| 125 | const fast = makeTimeline(); | |
| 126 | for (let i = 0; i < 60; i++) arrive(fast, 10 + i / 30); | |
| 127 | expect(delay(slow)).toBeGreaterThan(delay(fast)); | |
| 128 | expect(delay(slow)).toBeCloseTo((1 / 15) * LAG, 3); | |
| 129 | // Thirty a second wants less of a delay, and both stay inside the bounds. | |
| 130 | for (const d of [delay(slow), delay(fast)]) { | |
| 131 | expect(d).toBeGreaterThanOrEqual(LAG_MIN); | |
| 132 | expect(d).toBeLessThanOrEqual(LAG_MAX); | |
| 133 | } | |
| 134 | // A pause — a backgrounded tab, a reconnect — says nothing about the rate. | |
| 135 | arrive(fast, 40); | |
| 136 | expect(delay(fast)).toBeCloseTo(LAG_MIN, 3); | |
| 137 | }); | |
| 138 | ||
| 139 | test("the moment being drawn is always bracketed by samples on hand", () => { | |
| 140 | // Thirty snapshots a second, a frame every 16ms: the moment being drawn | |
| 141 | // always falls between the two samples on hand, never before them. | |
| 142 | const timeline = makeTimeline(); | |
| 143 | let x = 0; | |
| 144 | let last = { t: 0, x: 0 }; | |
| 145 | for (let i = 0; i < 40; i++) { | |
| 146 | const at = 10 + i / 30; | |
| 147 | arrive(timeline, at); | |
| 148 | x += 8; | |
| 149 | feed(timeline, "a", body("a", x), at); | |
| 150 | last = { t: at, x }; | |
| 151 | if (i < 3) continue; | |
| 152 | for (let f = 0; f < 2; f++) { | |
| 153 | const now = at + f * 0.016; | |
| 154 | const marks = timeline.bodies.get("a")!.marks as Mark[]; | |
| 155 | const when = now - delay(timeline); | |
| 156 | expect(when).toBeGreaterThanOrEqual(marks[0]!.t - 1e-9); | |
| 157 | expect(when).toBeLessThanOrEqual(marks[marks.length - 1]!.t + 1e-9); | |
| 158 | } | |
| 159 | } | |
| 160 | // And late is what late means: behind the newest word by the delay, which | |
| 161 | // at eight units an interval is between one and two of them. | |
| 162 | const drawn = bodyAt(timeline, body("a", last.x), last.t - delay(timeline)); | |
| 163 | expect(last.x - drawn.x).toBeGreaterThan(0); | |
| 164 | expect(last.x - drawn.x).toBeLessThan(20); | |
| 165 | }); |
static/interp.js added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * Everybody else, drawn between the things the server said about them. | |
| 3 | * | |
| 4 | * A remote body is only ever heard about a few dozen times a second, so it | |
| 5 | * has to be drawn somewhere in between. The old way was an exponential chase | |
| 6 | * — `now.x += (x - now.x) * 0.35` every frame — which is wrong twice: it is a | |
| 7 | * different speed at 60Hz than at 144Hz, and it is a pursuit that never | |
| 8 | * arrives, so every other player was permanently behind and rubbery. | |
| 9 | * | |
| 10 | * This is entity interpolation instead. Each body keeps the last few samples | |
| 11 | * with the times they landed, and the frame is drawn at `now - delay`, about | |
| 12 | * a snapshot interval in the past. That moment falls between two samples on | |
| 13 | * hand, so the body moves at exactly the speed the server moved it and | |
| 14 | * arrives exactly where the server put it — one interval late, which nobody | |
| 15 | * can see. A handful of samples rather than strictly two is what buys the | |
| 16 | * delay a margin: a snapshot that arrives late or early still brackets the | |
| 17 | * moment being drawn instead of stalling the body. | |
| 18 | * | |
| 19 | * When a snapshot really is missed the newest pair's own velocity is carried | |
| 20 | * forward for one interval and then held: a guess that long is better than a | |
| 21 | * stall, and a guess longer than that is fiction. The delay follows the rate | |
| 22 | * snapshots actually arrive at, so the same code is right at 15Hz and at | |
| 23 | * 30Hz without being told which it is. | |
| 24 | * | |
| 25 | * Nothing here is ever sent anywhere: it is a drawing, and the server is | |
| 26 | * still the only thing that says where anybody is. | |
| 27 | */ | |
| 28 | ||
| 29 | /** The render delay, as a multiple of the interval snapshots arrive at. */ | |
| 30 | export const LAG = 1.3; | |
| 31 | /** ...never tighter than this, never looser, in seconds. */ | |
| 32 | export const LAG_MIN = 0.045; | |
| 33 | export const LAG_MAX = 0.13; | |
| 34 | /** How many samples a body keeps: enough to bracket the delay, and no more. */ | |
| 35 | export const KEEP = 4; | |
| 36 | /** How fast the measured interval follows the arrivals. */ | |
| 37 | const INTERVAL_PULL = 0.25; | |
| 38 | /** A gap this long means the body was gone, not slow: start it again. */ | |
| 39 | export const GONE = 0.4; | |
| 40 | /** Faster than anything can run: a respawn, or a body back from nowhere. */ | |
| 41 | const TOP_SPEED = 400; | |
| 42 | ||
| 43 | const clamp = (n, low, high) => (n < low ? low : n > high ? high : n); | |
| 44 | ||
| 45 | /** The short way round from one angle to another, in (-π, π]. */ | |
| 46 | export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 47 | ||
| 48 | /** One timeline for the whole snapshot stream: the bodies, and the rate. */ | |
| 49 | export const makeTimeline = (interval = 1 / 15) => ({ bodies: new Map(), interval, at: 0 }); | |
| 50 | ||
| 51 | /** | |
| 52 | * A snapshot landed at `at` seconds. The gap since the last one is what the | |
| 53 | * render delay is built out of; a gap wilder than any real rate (a tab that | |
| 54 | * was in the background, a reconnect) says nothing about the rate and is | |
| 55 | * left out of it. | |
| 56 | */ | |
| 57 | export function arrive(timeline, at) { | |
| 58 | const gap = at - timeline.at; | |
| 59 | if (timeline.at > 0 && gap > 1 / 120 && gap < 0.5) | |
| 60 | timeline.interval += (gap - timeline.interval) * INTERVAL_PULL; | |
| 61 | timeline.at = at; | |
| 62 | return timeline; | |
| 63 | } | |
| 64 | ||
| 65 | /** How far behind the present this timeline is drawn, in seconds. */ | |
| 66 | export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX); | |
| 67 | ||
| 68 | /** The moment being drawn, given the clock now. */ | |
| 69 | export const renderTime = (timeline, now) => now - delay(timeline); | |
| 70 | ||
| 71 | const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle }); | |
| 72 | ||
| 73 | /** | |
| 74 | * What the server just said about one body. Out-of-order samples are | |
| 75 | * dropped; a body that has been away, or that has moved further than anyone | |
| 76 | * can run, starts fresh rather than sliding across the map to its new place. | |
| 77 | * | |
| 78 | * The drawn position lives on the track — one object per body, so a caller | |
| 79 | * may hold the answer it was given while asking about somebody else. | |
| 80 | */ | |
| 81 | export function feed(timeline, id, body, at) { | |
| 82 | const track = timeline.bodies.get(id); | |
| 83 | if (!track) { | |
| 84 | timeline.bodies.set(id, { marks: [mark(at, body)], out: { x: 0, y: 0, angle: 0 } }); | |
| 85 | return timeline; | |
| 86 | } | |
| 87 | const last = track.marks[track.marks.length - 1]; | |
| 88 | if (at <= last.t) return timeline; | |
| 89 | const gap = at - last.t; | |
| 90 | const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * gap + 4; | |
| 91 | if (gap > GONE || jumped) track.marks.length = 0; | |
| 92 | track.marks.push(mark(at, body)); | |
| 93 | if (track.marks.length > KEEP) track.marks.shift(); | |
| 94 | return timeline; | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Where a body was at a moment, given the samples it has — ascending in | |
| 99 | * time, and at least one. | |
| 100 | * | |
| 101 | * Between two samples this is a straight walk from one to the other at the | |
| 102 | * server's own speed. Before the oldest it holds, because running backwards | |
| 103 | * to meet a moment we have no answer for would be inventing one. After the | |
| 104 | * newest it carries the last pair's velocity for one interval — no more, | |
| 105 | * ever — and then holds there. | |
| 106 | */ | |
| 107 | export function sample(marks, when, out = { x: 0, y: 0, angle: 0 }) { | |
| 108 | const last = marks[marks.length - 1]; | |
| 109 | if (marks.length === 1 || when >= last.t) return beyond(marks, when, out); | |
| 110 | if (when <= marks[0].t) return at(marks[0], marks[0], 0, out); | |
| 111 | let i = marks.length - 2; | |
| 112 | while (i > 0 && marks[i].t > when) i -= 1; | |
| 113 | const from = marks[i]; | |
| 114 | const to = marks[i + 1]; | |
| 115 | const span = to.t - from.t; | |
| 116 | return at(from, to, span > 0 ? (when - from.t) / span : 1, out); | |
| 117 | } | |
| 118 | ||
| 119 | /** Past the newest sample: one interval of the last pair's velocity, held. */ | |
| 120 | function beyond(marks, when, out) { | |
| 121 | const last = marks[marks.length - 1]; | |
| 122 | const before = marks[marks.length - 2]; | |
| 123 | if (!before) return at(last, last, 0, out); | |
| 124 | const span = last.t - before.t; | |
| 125 | if (span <= 0) return at(last, last, 0, out); | |
| 126 | return at(before, last, clamp((when - before.t) / span, 1, 2), out); | |
| 127 | } | |
| 128 | ||
| 129 | /** A point `u` of the way from one sample to another; the turn is short. */ | |
| 130 | function at(from, to, u, out) { | |
| 131 | out.x = from.x + (to.x - from.x) * u; | |
| 132 | out.y = from.y + (to.y - from.y) * u; | |
| 133 | out.angle = from.angle + shortWay(from.angle, to.angle) * u; | |
| 134 | return out; | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * Where to draw a body out of a snapshot. One with no samples yet — the | |
| 139 | * first frame it is seen on — is drawn where the server says, which is the | |
| 140 | * only honest answer available. | |
| 141 | */ | |
| 142 | export function bodyAt(timeline, body, when, out = null) { | |
| 143 | const track = timeline.bodies.get(body.id); | |
| 144 | if (!track) return body; | |
| 145 | return sample(track.marks, when, out ?? track.out); | |
| 146 | } |
static/val.js +16 −18
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | 14 | import { makeBeeper } from "./beep.js"; |
| 15 | import { arrive, bodyAt, feed, makeTimeline, renderTime } from "./interp.js"; | |
| 15 | 16 | import { BODY_R, moveSpeed } from "../src/glide.ts"; |
| 16 | 17 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 17 | 18 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; |
| @@ -37,7 +38,11 @@ let world = null; // what predict.js slides against: the grid, from map.json | ||
| 37 | 38 | let snap = null; |
| 38 | 39 | let socket = null; |
| 39 | 40 | let agents = null; |
| 40 | const shown = new Map(); // smoothed positions, so 15 frames a second look like 60 | |
| 41 | // Everybody else, kept as the last few things the server said about them and | |
| 42 | // drawn between those — see interp.js. `drawAt` is the moment this frame is | |
| 43 | // drawing, a snapshot interval behind the present. | |
| 44 | const timeline = makeTimeline(); | |
| 45 | let drawAt = 0; | |
| 41 | 46 | const keys = new Set(); |
| 42 | 47 | // Where this person is looking: the browser's between snapshots, and the |
| 43 | 48 | // server's word taken only when it is saying something we did not. See |
| @@ -82,6 +87,11 @@ function connect() { | ||
| 82 | 87 | if (data.t !== "s") return; |
| 83 | 88 | snap = data; |
| 84 | 89 | snapAt = performance.now(); |
| 90 | // Timed samples for every body in it, ours included: the drawing walks | |
| 91 | // between them rather than chasing the newest one. | |
| 92 | arrive(timeline, snapAt / 1000); | |
| 93 | feed(timeline, data.me.id, data.me, snapAt / 1000); | |
| 94 | for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000); | |
| 85 | 95 | // The server's angle is taken only when it is a correction, never as the |
| 86 | 96 | // routine echo of the angle we sent it a moment ago. |
| 87 | 97 | settle(head, data.me, snapAt / 1000); |
| @@ -283,26 +293,13 @@ addEventListener("resize", resize); | ||
| 283 | 293 | const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1); |
| 284 | 294 | const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); |
| 285 | 295 | |
| 286 | function smooth(id, x, y, angle) { | |
| 287 | const now = shown.get(id); | |
| 288 | if (!now) { | |
| 289 | const fresh = { x, y, angle }; | |
| 290 | shown.set(id, fresh); | |
| 291 | return fresh; | |
| 292 | } | |
| 293 | now.x += (x - now.x) * 0.35; | |
| 294 | now.y += (y - now.y) * 0.35; | |
| 295 | const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 296 | now.angle += turn * 0.4; | |
| 297 | return now; | |
| 298 | } | |
| 299 | ||
| 300 | 296 | /** |
| 301 | * Everybody else is smoothed toward the last position the server sent. | |
| 297 | * Everybody else is drawn at `drawAt`: between the two samples that bracket | |
| 298 | * it, so they move at the server's own speed and arrive where it put them. | |
| 302 | 299 | * You are not: your own body is the one this browser can run forward |
| 303 | * honestly, and smoothing it is what dragged the camera backwards. | |
| 300 | * honestly, and drawing it late is what dragged the camera backwards. | |
| 304 | 301 | */ |
| 305 | const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle)); | |
| 302 | const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(timeline, p, drawAt)); | |
| 306 | 303 | |
| 307 | 304 | /** |
| 308 | 305 | * Your own movement is only guessed at while the round is live and you are |
| @@ -345,6 +342,7 @@ function frame(now) { | ||
| 345 | 342 | requestAnimationFrame(frame); |
| 346 | 343 | const dt = Math.min(0.1, (now - last) / 1000); |
| 347 | 344 | last = now; |
| 345 | drawAt = renderTime(timeline, now / 1000); | |
| 348 | 346 | if (!map) return; |
| 349 | 347 | gunTick(gun, dt); |
| 350 | 348 | ctx.clearRect(0, 0, view.width, view.height); |