+
+
🎮 Doom 的故事
+
+
1993 年,id Software 发布了一款彻底改变游戏行业的作品 ——《Doom》。在那个 CPU 主频只有 66 MHz、没有 GPU 的年代,John Carmack 用纯软件渲染实现了流畅的"伪 3D"画面,让全世界的玩家第一次体验到了第一人称射击(FPS)的魅力。
+
+
Doom 并不是真正的 3D 游戏。它的世界本质上是一张 2D 网格地图,通过一种叫做 Raycasting(光线投射)的算法,把 2D 的地图信息"投影"成看起来像 3D 的画面。这种技术后来被称为 2.5D —— 介于 2D 和 3D 之间。
+
+
+ 有一天我在博客里做完了贪吃蛇之后,突然想:能不能在浏览器里用 Canvas 2D 也复刻一个 Doom?于是就有了这个项目 —— 一个完全用 JavaScript + Canvas 2D 实现的 FPS 游戏,无需 WebGL,无需任何外部资源文件,所有的渲染、音效、物理全部程序化生成。
+
+
+
+
🔎 Raycasting:从 2D 地图到伪 3D 画面
+
+
Raycasting 的核心思想非常优雅:对屏幕上的每一列像素,从玩家位置发射一条射线,找到它碰到的第一面墙,根据距离来决定这面墙在屏幕上画多高。
+
+
想象你站在一个由方块组成的迷宫里。你的视野是 60 度,屏幕宽度是 960 像素。那么你就需要发射 960 条射线,均匀分布在这 60 度的扇形范围内:
+
+
// 对每一列像素发射射线
+for (var col = 0; col < SCREEN_W; col++) {
+ // 射线角度 = 玩家朝向 - 半FOV + 当前列的偏移
+ var rayAngle = player.angle - HALF_FOV + (col / SCREEN_W) * FOV;
+ // ... 用DDA算法找到墙壁
+}
+
+
DDA 算法
+
+
DDA(Digital Differential Analyzer) 是 Raycasting 的核心。它不是沿射线一小步一小步地前进(那样太慢),而是沿网格线跳跃 —— 每一步精确地跳到下一条网格线,检查那个格子是不是墙:
+
+
// DDA 核心:在X边界和Y边界之间交替前进
+if (sideDistX < sideDistY) {
+ sideDistX += deltaDistX; // 跳到下一条竖直网格线
+ mapX += stepX;
+ side = 0; // 碰到的是东/西面的墙
+} else {
+ sideDistY += deltaDistY; // 跳到下一条水平网格线
+ mapY += stepY;
+ side = 1; // 碰到的是南/北面的墙
+}
+
+
这个算法的精妙之处在于:每次循环只需要一次比较和一次加法,不需要任何三角函数运算,所以即使在 1993 年的硬件上也能达到 30+ FPS。
+
+
鱼眼矫正
+
+
如果直接用射线到墙壁的欧几里得距离来计算墙高,你会看到一个诡异的"鱼眼"效果 —— 画面边缘的墙壁看起来向外膨胀弯曲。这是因为屏幕边缘的射线比中心的射线走了更远的路。
+
+
修复方法很简单 —— 使用垂直距离而不是实际距离:
+
+
// 鱼眼矫正:用垂直距离代替实际距离
+// DDA 算法天然给出垂直距离:
+var perpDist;
+if (side === 0) perpDist = sideDistX - deltaDistX;
+else perpDist = sideDistY - deltaDistY;
+
+// 墙壁高度 = 屏幕高度 / 垂直距离
+var wallH = Math.floor(SCREEN_H / perpDist);
+
+
距离着色与面着色
+
+
为了增强立体感,我们用了两个技巧:
+
+ - 距离衰减:墙壁颜色随距离变暗,
shade = max(0.15, 1 - dist / MAX_DEPTH)
+ - 面朝向差异:南北面(side=1)的亮度额外乘以 0.7,这样转角处就能看到明暗交替,增强空间感
+
+
+
+
👤 精灵系统:2D 角色在 3D 空间中
+
+
敌人和道具在 Raycasting 引擎中是 Billboard Sprites —— 它们是扁平的 2D 图像,但始终正对玩家(就像广告牌一样)。这样无论你从哪个角度看,它们都"看起来"是 3D 的。
+
+
精灵渲染的关键步骤:
+
+
+ - 世界坐标→屏幕坐标:计算精灵相对于玩家的角度偏差,映射到屏幕 X 位置
+ - 距离排序:先画远的,再画近的(画家算法)
+ - 深度缓冲裁剪:逐列比较精灵距离和该列墙壁距离,只画精灵比墙近的部分
+
+
+
// 精灵投影:世界坐标 → 屏幕位置
+var spriteAngle = Math.atan2(dy, dx) - player.angle;
+var screenX = SCREEN_W / 2 * (1 + spriteAngle / HALF_FOV);
+var spriteH = SCREEN_H / dist; // 距离越近,画得越大
+
+// 深度裁剪:逐列检查
+for (var col = startX; col < endX; col++) {
+ if (dist < depthBuf[col]) {
+ // 这一列精灵比墙近,画出来
+ drawSpriteColumn(col, ...);
+ }
+}
+
+
有趣的是,本项目的敌人不是用图片贴图,而是用 Canvas 程序化绘制 —— 头、身体、手臂、腿都是用 fillRect 和 arc 拼出来的。这意味着整个游戏没有任何外部资源文件。
+
+
+
🖱 鼠标旋转的坑
+
+
FPS 游戏最重要的交互就是鼠标瞄准。在浏览器中,我们使用 Pointer Lock API 来获取鼠标的相对移动量,然后转化为视角旋转:
+
+
canvas.requestPointerLock(); // 锁定鼠标
+
+document.addEventListener('mousemove', function(e) {
+ if (pointerLocked) {
+ mouseMovX += e.movementX; // 累加水平移动量
+ }
+});
+
+// 每帧应用旋转
+player.angle += mouseMovX * MOUSE_SENS;
+mouseMovX = 0;
+
+
听起来很简单,但在实际开发中,鼠标视角会突然跳变 —— 转到某个方向时,画面猛地转了 90 度。
+
+
这个 bug 困扰了我很久,经历了 4 次修复迭代:
+
+
+ | 尝试 | 方案 | 结果 |
+ | 第 1 次 | 角度归一化到 [0, 2π] | ❌ 没用 |
+ | 第 2 次 | per-event clamp ±50px + 帧级 clamp ±150px | ❌ 依然跳变 |
+ | 第 3 次 | 指数移动平均 (EMA) 平滑 | ❌ 更糟了 —— 操作感变得迟钝粘滞 |
+ | 第 4 次 | 直接丢弃 |movementX| > 200 的异常事件 | ✅ 完美解决! |
+
+
+
最终的原因是 浏览器的 Pointer Lock 实现存在 bug:某些帧会报出离谱的 movementX 值(比如突然 +500),这并不是真实的鼠标移动。
+
+
// 最终方案:丢弃离群值
+document.addEventListener('mousemove', function(e) {
+ if (pointerLocked) {
+ // 丢弃异常跳变(浏览器 Pointer Lock bug)
+ if (Math.abs(e.movementX) < 200) {
+ mouseMovX += e.movementX;
+ }
+ }
+});
+
+
+ 教训:有时候最简单的方案反而最有效。复杂的平滑算法不仅没解决问题,还引入了新的问题。直接找到异常数据并丢弃才是正解。
+
+
+
+
💣 手雷物理模拟
+
+
手雷的物理系统是整个项目最有趣的部分之一。它模拟了真实的抛物运动和弹跳效果,虽然只有几十行代码,但视觉效果非常满意。
+
+
三轴运动
+
+
虽然是 2.5D 的游戏,手雷的运动是在 3 个轴上进行的:x/y 是水平位置(地图上的坐标),z 是垂直高度。
+
+
// 抛出手雷
+grenades.push({
+ x: player.x, y: player.y,
+ dx: cos * 6, dy: sin * 6, // 水平速度(朝向方向)
+ z: 0.5, // 初始高度(手持高度)
+ dz: 3.0, // 向上抛出的初速度
+ life: 3.0 // 3秒引信
+});
+
+// 每帧更新
+g.dz -= 9.8 * dt; // 重力加速度
+g.z += g.dz * dt; // 更新高度
+
+
地面弹跳
+
+
当手雷落到地面(z ≤ 0)时,垂直速度反转并乘以衰减系数,模拟非弹性碰撞:
+
+
if (g.z <= 0) {
+ g.z = 0;
+ if (Math.abs(g.dz) > 0.5) {
+ g.dz = -g.dz * 0.45; // 弹跳,保留45%能量
+ g.dx *= 0.7; // 水平速度也衰减
+ g.dy *= 0.7;
+ } else {
+ g.dz = 0; // 能量不够了,停止弹跳
+ g.dx *= 0.92; // 地面滚动摩擦
+ g.dy *= 0.92;
+ }
+}
+
+
墙壁反弹
+
+
墙壁碰撞是 X 轴和 Y 轴独立检测 的。这意味着手雷打到墙角时,X 和 Y 方向都会反转,自然地弹回来:
+
+
// X方向碰墙?反转X速度
+if (MAP[myO][mxN] !== 0) {
+ g.dx = -g.dx * 0.5; // 50%能量保留
+ nx = g.x; // 不穿墙
+}
+// Y方向碰墙?反转Y速度
+if (MAP[myN][mxO] !== 0) {
+ g.dy = -g.dy * 0.5;
+ ny = g.y;
+}
+
+
手雷在飞行时还会在地面投射一个椭圆形阴影,帮助玩家判断落点位置。
+
+
+
🤖 敌人 AI 状态机
+
+
每个敌人都运行着一个简单的状态机,有 4 个状态:
+
+
+ | 状态 | 行为 | 转换条件 |
+ | IDLE | 原地缓慢巡逻 | 听到枪声或看到玩家 → ALERT |
+ | ALERT | 朝声音方向转身 | 看到玩家 → CHASE |
+ | CHASE | 朝玩家移动 | 进入攻击范围 → ATTACK |
+ | ATTACK | 开火或近战 | 失去视线 → IDLE |
+
+
+
"看到玩家"的判定用的是一个简化的 Raycast:沿敌人到玩家的方向,每步 0.3 检查是否碰到墙壁。如果一路畅通,就说明视线没被遮挡。
+
+
敌人之间、敌人和玩家之间还有碰撞阻挡,防止它们重叠在一起。这个碰撞半径经历了多次调整 —— 太小会重叠,太大会卡在门口走不过去。
+
+
+
🎵 程序化音效
+
+
整个游戏的所有音效都是用 Web Audio API 实时生成的,没有一个外部音频文件。每种武器和音效都是由振荡器(Oscillator)、噪声缓冲(Noise Buffer)、滤波器(BiquadFilter)和增益节点(Gain)组合而成:
+
+
+ | 音效 | 实现方式 |
+ | 手枪 | 白噪声 + 低通滤波器 800Hz + 快速衰减 |
+ | 霰弹枪 | 白噪声 + 低通 600Hz + 更长的衰减 |
+ | 机枪 | 白噪声 + 带通滤波器 + 极短脉冲 |
+ | 狙击枪 | 锯齿波 150→30Hz + 低通 800Hz(深沉的开裂声) |
+ | 爆炸 | 长白噪声 + 低通 300Hz + 慢衰减(沉闷的轰鸣) |
+ | 匕首 | 白噪声 + 高通 3000Hz(尖锐的挥砍声) |
+ | 脚步 | 白噪声 + 低通 200Hz + 极短脉冲 |
+
+
+
// 以手枪为例:白噪声 + 低通滤波 + 快速衰减
+case 'pistol':
+ bufferSize = audioCtx.sampleRate * 0.15;
+ buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
+ output = buffer.getChannelData(0);
+ for (var i = 0; i < bufferSize; i++)
+ output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
+ // ... 连接低通滤波器和增益节点
+
+
+
🔒 反作弊设计
+
+
排行榜系统需要防止分数作弊。虽然客户端的任何方案都不可能完全防住,但合理的设计可以大幅提高作弊门槛:
+
+
+ - Token 验证:提交分数时需要附带
md5(score + '_' + duration + '_' + md5(serverSalt)),salt 每天更换
+ - 合理性检查:分数 0~99999,时长 0~36000 秒
+ - IP 限速:每个 IP 每 5 秒只能提交一次
+ - 数据清理:只保留前 20 名,超过 3 周的记录自动删除
+
+
+
+ 一个有趣的小坑:最初 salt 是通过 WordPress 的 esc_js() 传给前端的,但这个函数会把 salt 中的特殊字符转义,导致前后端计算的 token 不一致。解决方案是在传递之前先对 salt 做一次 md5() —— 哈希值只包含 0-9a-f,不会被转义。
+
+
+
+
🚀 技术栈总结
+
+
+ | 模块 | 技术 |
+ | 渲染引擎 | Canvas 2D + Raycasting (DDA 算法) |
+ | 输入控制 | Pointer Lock API + 离群值过滤 |
+ | 音效系统 | Web Audio API 程序化生成 |
+ | 物理模拟 | 欧拉积分 + 弹性碰撞 |
+ | AI 系统 | 有限状态机 + 视线检测 |
+ | 后端 | WordPress AJAX + MySQL |
+ | 反作弊 | MD5 token + 服务端 salt |
+ | 外部依赖 | 无(纯原生 JS,无框架、无图片、无音频文件) |
+
+
+
整个游戏的代码量约 1800 行 JavaScript,加上 300 行 CSS 和 80 行 PHP,是一个完全自包含的小项目。它证明了即使在 2026 年,Raycasting 这个 1992 年的技术依然有它独特的魅力 —— 简单、高效、而且实现起来非常有趣。
+
+
如果你想挑战一下,试试通关两个关卡吧! 😊
+
+
+
+
🎮 The Story of Doom
+
+
In 1993, id Software released a game that changed the industry forever — Doom. In an era when CPUs ran at 66 MHz and GPUs didn't exist, John Carmack achieved smooth "pseudo-3D" rendering through pure software, giving the world its first taste of the first-person shooter (FPS) genre.
+
+
Doom isn't truly a 3D game. Its world is fundamentally a 2D grid map, projected into a 3D-looking image through an algorithm called Raycasting. This technique came to be known as 2.5D — somewhere between 2D and 3D.
+
+
+ One day, after building a Snake game for my blog, I thought: could I recreate Doom in the browser using Canvas 2D? And so this project was born — an FPS game built entirely with JavaScript + Canvas 2D. No WebGL, no external assets — all rendering, sound effects, and physics are procedurally generated.
+
+
+
+
🔎 Raycasting: From 2D Maps to Pseudo-3D
+
+
The core idea of Raycasting is elegant: for each column of pixels on screen, cast a ray from the player's position, find the first wall it hits, and draw that wall at a height determined by the distance.
+
+
Imagine standing in a maze made of blocks. Your field of view is 60 degrees, and the screen is 960 pixels wide. You cast 960 rays, evenly distributed across that 60-degree arc:
+
+
// Cast a ray for each screen column
+for (var col = 0; col < SCREEN_W; col++) {
+ // Ray angle = player facing - half FOV + column offset
+ var rayAngle = player.angle - HALF_FOV + (col / SCREEN_W) * FOV;
+ // ... find wall using DDA algorithm
+}
+
+
The DDA Algorithm
+
+
DDA (Digital Differential Analyzer) is the heart of Raycasting. Instead of stepping along the ray in tiny increments (too slow), it jumps along grid boundaries — each step lands exactly on the next grid line, checking if that cell is a wall:
+
+
// DDA core: alternate between X and Y grid boundaries
+if (sideDistX < sideDistY) {
+ sideDistX += deltaDistX; // jump to next vertical grid line
+ mapX += stepX;
+ side = 0; // hit an East/West wall face
+} else {
+ sideDistY += deltaDistY; // jump to next horizontal grid line
+ mapY += stepY;
+ side = 1; // hit a North/South wall face
+}
+
+
The beauty of this algorithm: each iteration needs only one comparison and one addition — no trigonometry — so it could run at 30+ FPS even on 1993 hardware.
+
+
Fish-eye Correction
+
+
If you use the raw Euclidean distance from ray to wall, you get a bizarre "fish-eye" effect — walls at the screen edges appear to bulge outward. This happens because edge rays travel further than center rays.
+
+
The fix is simple — use perpendicular distance instead of actual distance:
+
+
// Fish-eye correction: use perpendicular distance
+// DDA naturally provides this:
+var perpDist;
+if (side === 0) perpDist = sideDistX - deltaDistX;
+else perpDist = sideDistY - deltaDistY;
+
+// Wall height = screen height / perpendicular distance
+var wallH = Math.floor(SCREEN_H / perpDist);
+
+
Distance & Face Shading
+
+
To enhance depth perception, two techniques are used:
+
+ - Distance attenuation: Wall colors darken with distance,
shade = max(0.15, 1 - dist / MAX_DEPTH)
+ - Face orientation: N/S faces (side=1) are dimmed by an extra 0.7x, creating visible light/dark alternation at corners
+
+
+
+
👤 Sprite System: 2D Characters in 3D Space
+
+
Enemies and items in a Raycasting engine are Billboard Sprites — flat 2D images that always face the player (like a billboard). This makes them "look" 3D from any angle.
+
+
Key steps in sprite rendering:
+
+
+ - World → Screen projection: Calculate the sprite's angular offset from the player, map to screen X position
+ - Distance sorting: Draw far sprites first, near ones last (painter's algorithm)
+ - Depth buffer clipping: Compare sprite distance against wall distance column-by-column, only draw where the sprite is closer
+
+
+
// Sprite projection: world coords → screen position
+var spriteAngle = Math.atan2(dy, dx) - player.angle;
+var screenX = SCREEN_W / 2 * (1 + spriteAngle / HALF_FOV);
+var spriteH = SCREEN_H / dist; // closer = larger
+
+// Depth clipping: per-column check
+for (var col = startX; col < endX; col++) {
+ if (dist < depthBuf[col]) {
+ // This column is in front of the wall, draw it
+ drawSpriteColumn(col, ...);
+ }
+}
+
+
Interestingly, enemies in this project aren't texture-mapped — they're drawn procedurally with Canvas. Heads, bodies, arms, and legs are assembled from fillRect and arc calls. This means the entire game has zero external asset files.
+
+
+
🖱 The Mouse Rotation Bug
+
+
Mouse aiming is the most critical interaction in an FPS. In the browser, we use the Pointer Lock API to capture relative mouse movement and convert it to view rotation:
+
+
canvas.requestPointerLock(); // lock the mouse
+
+document.addEventListener('mousemove', function(e) {
+ if (pointerLocked) {
+ mouseMovX += e.movementX; // accumulate horizontal movement
+ }
+});
+
+// Apply rotation each frame
+player.angle += mouseMovX * MOUSE_SENS;
+mouseMovX = 0;
+
+
Sounds simple, but in practice, the view would suddenly snap — spinning 90 degrees in a single frame.
+
+
This bug tormented me through 4 fix iterations:
+
+
+ | Attempt | Approach | Result |
+ | #1 | Normalize angle to [0, 2π] | ❌ No effect |
+ | #2 | Per-event clamp ±50px + frame clamp ±150px | ❌ Still snapping |
+ | #3 | Exponential Moving Average (EMA) smoothing | ❌ Worse — controls felt sluggish and drifty |
+ | #4 | Discard events with |movementX| > 200 | ✅ Perfect fix! |
+
+
+
The root cause: a browser bug in the Pointer Lock implementation that occasionally reports wildly incorrect movementX values (e.g., a sudden +500), which don't represent real mouse movement.
+
+
// Final solution: discard outliers
+document.addEventListener('mousemove', function(e) {
+ if (pointerLocked) {
+ // Discard anomalous jumps (browser Pointer Lock bug)
+ if (Math.abs(e.movementX) < 200) {
+ mouseMovX += e.movementX;
+ }
+ }
+});
+
+
+ Lesson learned: Sometimes the simplest solution is the most effective. Complex smoothing algorithms didn't solve the problem — they made it worse. Identifying and discarding anomalous data was the real fix.
+
+
+
+
💣 Grenade Physics Simulation
+
+
The grenade physics system is one of the most satisfying parts of this project. It simulates realistic parabolic motion and bouncing with just a few dozen lines of code.
+
+
Three-Axis Motion
+
+
Despite being a 2.5D game, grenade motion uses 3 axes: x/y for horizontal position (map coordinates), and z for vertical height.
+
+
// Throw a grenade
+grenades.push({
+ x: player.x, y: player.y,
+ dx: cos * 6, dy: sin * 6, // horizontal velocity (facing direction)
+ z: 0.5, // initial height (hand level)
+ dz: 3.0, // upward throw velocity
+ life: 3.0 // 3-second fuse
+});
+
+// Per-frame update
+g.dz -= 9.8 * dt; // gravity
+g.z += g.dz * dt; // update height
+
+
Ground Bounce
+
+
When the grenade hits the ground (z ≤ 0), vertical velocity is reversed and multiplied by a damping factor, simulating inelastic collision:
+
+
if (g.z <= 0) {
+ g.z = 0;
+ if (Math.abs(g.dz) > 0.5) {
+ g.dz = -g.dz * 0.45; // bounce, retain 45% energy
+ g.dx *= 0.7; // horizontal speed also decays
+ g.dy *= 0.7;
+ } else {
+ g.dz = 0; // not enough energy, stop bouncing
+ g.dx *= 0.92; // rolling friction
+ g.dy *= 0.92;
+ }
+}
+
+
Wall Bounce
+
+
Wall collisions are detected independently on the X and Y axes. This means a grenade hitting a corner naturally reverses both directions:
+
+
// Hit wall on X axis? Reverse X velocity
+if (MAP[myO][mxN] !== 0) {
+ g.dx = -g.dx * 0.5; // 50% energy retained
+ nx = g.x; // don't pass through
+}
+// Hit wall on Y axis? Reverse Y velocity
+if (MAP[myN][mxO] !== 0) {
+ g.dy = -g.dy * 0.5;
+ ny = g.y;
+}
+
+
During flight, the grenade also casts an elliptical shadow on the ground to help players judge the landing point.
+
+
+
🤖 Enemy AI State Machine
+
+
Each enemy runs a simple state machine with 4 states:
+
+
+ | State | Behavior | Transition |
+ | IDLE | Slow patrol in place | Hears gunfire or sees player → ALERT |
+ | ALERT | Turn toward sound | Sees player → CHASE |
+ | CHASE | Move toward player | In attack range → ATTACK |
+ | ATTACK | Fire or melee | Loses line of sight → IDLE |
+
+
+
"Seeing the player" uses a simplified raycast: step along the enemy-to-player direction in 0.3 increments, checking for wall hits. If the path is clear, line of sight is confirmed.
+
+
Enemies also have mutual collision blocking with each other and with the player, preventing overlap. The collision radius went through several adjustments — too small and they overlap, too large and they get stuck in doorways.
+
+
+
🎵 Procedural Sound Effects
+
+
Every sound in the game is generated in real-time using the Web Audio API — not a single audio file is loaded. Each weapon and effect is composed from Oscillators, Noise Buffers, BiquadFilters, and Gain nodes:
+
+
+ | Sound | Implementation |
+ | Pistol | White noise + lowpass 800Hz + fast decay |
+ | Shotgun | White noise + lowpass 600Hz + longer decay |
+ | Machine gun | White noise + bandpass filter + ultra-short pulse |
+ | Sniper | Sawtooth 150→30Hz + lowpass 800Hz (deep crack) |
+ | Explosion | Long white noise + lowpass 300Hz + slow decay (muffled boom) |
+ | Knife | White noise + highpass 3000Hz (sharp slash) |
+ | Footstep | White noise + lowpass 200Hz + ultra-short pulse |
+
+
+
// Example: Pistol - white noise + lowpass + fast decay
+case 'pistol':
+ bufferSize = audioCtx.sampleRate * 0.15;
+ buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
+ output = buffer.getChannelData(0);
+ for (var i = 0; i < bufferSize; i++)
+ output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
+ // ... connect lowpass filter and gain node
+
+
+
🔒 Anti-Cheat Design
+
+
The leaderboard needs protection against score manipulation. While no client-side solution is bulletproof, reasonable design can raise the barrier significantly:
+
+
+ - Token verification: Score submissions require
md5(score + '_' + duration + '_' + md5(serverSalt)), with the salt rotating daily
+ - Sanity checks: Score 0–99999, duration 0–36000 seconds
+ - IP rate limiting: One submission per IP every 5 seconds
+ - Data cleanup: Keep only top 20, auto-delete records older than 3 weeks
+
+
+
+ A fun gotcha: initially the salt was passed to the frontend via WordPress's esc_js(), which escapes special characters in the salt, causing token mismatches between client and server. The fix was to md5() the salt before passing it — hex digests contain only 0-9a-f and can't be corrupted by escaping.
+
+
+
+
🚀 Tech Stack Summary
+
+
+ | Module | Technology |
+ | Rendering | Canvas 2D + Raycasting (DDA algorithm) |
+ | Input | Pointer Lock API + outlier filtering |
+ | Audio | Web Audio API procedural generation |
+ | Physics | Euler integration + inelastic collision |
+ | AI | Finite state machine + line-of-sight detection |
+ | Backend | WordPress AJAX + MySQL |
+ | Anti-cheat | MD5 token + server-side salt |
+ | Dependencies | None (vanilla JS, no frameworks, no images, no audio files) |
+
+
+
The entire game is roughly 1,800 lines of JavaScript, plus 300 lines of CSS and 80 lines of PHP — a fully self-contained mini-project. It proves that even in 2026, Raycasting — a technique from 1992 — still has a unique charm: simple, efficient, and incredibly fun to implement.
+
+
If you're up for a challenge, try beating both levels! 😊
+
+