aimDISCORD · Development diary
From version 0.1.0 to 0.12.0 plus the "cursor aiming" fix, 14–16 October 2026. 48 commits, 52 automated tests, running on the Ubuntu server 10.10.10.21 and on Vercel.
---
1. What aimDISCORD is
aimDISCORD is a Discord Activity: a Valorant-style aim trainer that runs right inside the Discord window. Players click the Activity icon in a voice or text channel and start playing, with nothing to install.
The companion bot has:
- leaderboards per room, per server and global;
- ranks from Iron 1 to Radiant, tracked separately for each mode;
- daily challenges and 1v1 duels;
- profile card images and rank-based roles;
- record announcements in chat.
What's there now:
- 10 modes: Gridshot, Spidershot, Microshot, Headshot, Timing, Peek (Giữ góc), Tracking, Strafe, Switching, Reflex (Phản xạ).
- A first-person 3D shooting room, with the mouse turning at exactly Valorant's sensitivity.
- Weapon skins: about 1,400 Valorant skins to choose from, 21 "in-hand" skins, and your own uploaded weapon images.
- A Quiz tab (Đố vui) with 5 brutally hard games about maps, agents and weapons.
- The "Your Valorant" card (Valorant của bạn): your real rank, pulled from your Riot ID.
Structure:
activity/: the part that runs inside Discord (Vite + TypeScript + three.js), hosted on Vercel.src/: the Node.js server, with the discord.js bot, the web API, SQLite and the card images drawn with@napi-rs/canvas. It runs in Docker on my own server and reaches the internet through Tailscale Funnel.src/domain/game.js: the rules for every mode, shared by both the browser and the server.
---
2. The idea: why build this when Aim Lab, Kovaak's and 3D Aim Trainer already exist?
Aim trainers already exist and they're very good:
- Aim Lab (free, on Steam);
- Kovaak's (paid);
- 3D Aim Trainer (app.3daimtrainer.com, runs on the web and is the one I looked to most).
I didn't rebuild one to beat them on depth, but because there are gaps they don't fill:
- It lives right inside Discord. Groups of friends who play Valorant are already sitting in a voice channel. No installing Steam, no opening a separate browser: one click and the whole group is in, seeing each other's scores in the same room.
- Rankings per server. Aim Lab compares you with the whole world; here you're compared with your own friends in the server, with rank roles, record announcements and 1v1 duels right in chat. Competing with people you know is what keeps people coming back.
- Made only for Valorant. · Sensitivity uses Valorant's exact scale (0.07° per mouse count × sensitivity, 103° horizontal field of view), so typing in your in-game number makes the mouse turn exactly the same. · Ranks use the game's real icons, and the crosshair reads Valorant profile codes. · There are Valorant weapon skins, a Peek mode that simulates an opponent peeking out from a corner, and callout quizzes.
- Vietnamese from start to finish, including the card images in chat.
- To learn. A project big enough to touch almost every layer of software: · 3D graphics in the browser, low-level mouse handling; · server-side anti-cheat, databases; · Discord bots, OAuth, Docker, networking, deployment; · image processing, interface design.
---
3. Timeline
Phase 1: foundations (0.1 → 0.5, 14–15 October)
- 0.1.0, the basic game: Gridshot, Microshot, Tracking, Reflex. · Each round has a seed (a random starting number). The browser records every click and mouse movement into a log and sends it to the server. The server replays that exact round from the seed and the log to compute the score itself, instead of trusting the number the browser sends. · There's a leaderboard, the
/bxhand/hosocommands, Docker, and a daily database backup. - 0.2.0: ranks Iron 1 → Immortal 3 with RR, plus Radiant for the global top 10. A crosshair editor that reads and writes Valorant codes.
- 0.3.0: real Valorant rank icons (under Riot's fan content policy). Added an intro screen and a rank-up effect that shatters the old badge.
- 0.4.0: the lobby is a live shooting range with targets you can shoot. Added a Tailscale Funnel service to expose the API to the internet.
- 0.5.0: Spidershot, Headshot, Strafe; daily challenges, 1v1 duels, card images, rank-based roles.
Phase 2: finding the look (0.6 → 0.7)
- 0.6.0: the "Radianite Night" interface, dark background, frosted glass.
- 0.7.0: the "Cover Issue" interface in a fashion magazine style, blush pink and silver, set in Playfair Display. First time with mouse sensitivity and pointer lock.
Phase 3: like the real Valorant (0.8 → 0.10, evening of 15 October to the morning of 16 October)
- Fixing sensitivity: a bug report that "sensitivity doesn't work", then "it's still slower than the real thing". I switched to Valorant's exact scale, then changed how the crosshair moves to follow perspective (see section 4.1).
- 0.8.0, moving to 3D: · each round is a 3D room, and the mouse turns the camera like a real FPS; · added Timing (targets swell up then fade) and Switching (three drifting targets that break once you hold the crosshair on them long enough); · started season 2: ranks and leaderboards were reset from scratch because 3D plays completely differently from the 2D version.
- First deployment to Discord. Hit the Discord error "User-installed Apps require verification to use activities in servers with more than 25 members", solved by installing the app to the server instead of to the user.
- 0.9.0, weapon skins: · a hand-built 3D gun with 7 finishes; · a picker with about 1,400 real Valorant skins from valorant-api.com; · a first-person weapon image matched to the in-game screenshot
fov.png, sized at 54% of the screen height; · players can upload their own weapon images. - Cutting skins out of videos: I tried cutting in-hand weapon images out of skin showcase videos (Champions 2021, then 20 Vandals). In the end I dropped it and used the set of 21 skin images in the
skins/folder instead. - 0.10.0: the "Tactical HUD" interface, like the game client (Chakra Petch type, teal #2ee6c5, chamfered corners).
- Tried robot heads and agent heads as targets, then went back to spheres.
- Three rounds of FPS optimisation, ending with a thorough check of everything (see section 4.6).
Phase 4: even more like Valorant (0.11, 16 October)
- Peek mode: there are boxes, door frames and wall edges; an enemy head slides out from a corner, stays for 0.45–0.9 seconds, then pulls back; shooting the cover is a miss; the earlier you shoot, the more points you get.
- Map backdrops on the back wall. Later dropped because they were just panoramas, not real scenes inside the map.
- The Quiz tab, first version, with 4 games.
Phase 5: lean and hard (0.12 plus the fix, 16 October)
- Home page: a single PLAY NOW (CHƠI NGAY) button and 3 cards (Peek, Today's Challenge, Quiz).
- Brutally hard quizzes: 357 in-map screenshots from the Valorant wiki, with a timer for each question.
- "Your Valorant": real rank through the HenrikDev API.
- Also added: asking for your sensitivity the first time you open the app, a 5-minute Warm-up, a hit sound, and sharing your results to the channel.
- The cursor aiming fix: for when Discord won't allow mouse lock (small window, not fullscreen).
---
4. Problems and how I solved them
4.1. Mouse sensitivity didn't feel like the game
- Problem: sensitivity "didn't work", then it was "slower than the real thing".
- Cause: · At first the crosshair moved on a flat 2D plane in pixels, not by rotation angle. · Without pointer lock, the browser reports movement in CSS pixels, not in mouse "counts". With display scaling at 125% or 150%, the numbers were off.
- Solution: · Use Valorant's exact formula: each count turns 0.07° × sensitivity. · Multiply movement by
devicePixelRatiowhen the mouse can't be locked. · Switch to a real 3D camera turning in yaw and pitch, so the 103° field of view matches the game. - Lesson: if you want it to "feel like the game", you have to understand how the game measures things (what units, what field of view), not guess by eye.
4.2. Anyone could submit a fake score
- Problem: the browser is in the player's hands, and they can change anything.
- Solution: · The game rules live in
src/domain/game.js, shared by the browser and the server. · The server receives the seed and the event log[type, time, x, y], and replays it to compute the score. · Added suspicion flags: two hits less than 70 ms apart, 100% hits that are too fast, no mouse movement at all, play time shorter than the round length. · Flagged rounds are kept but don't go on the leaderboard. - Lesson: · The "authoritative server" architecture. · A seeded random number generator (mulberry32) that gives the same results on every machine. · Game rules have to be deterministic: the same input gives the same result.
4.3. Discord blocked the Activity in servers with more than 25 people
- Problem: with the app installed as a "user install", Discord requires verification to run it in large servers.
- Solution: install the app to the server (guild install) with an invite link carrying the
applications.commandsandbotscopes.
4.4. A "skin swapper" repo on GitHub
- Problem: someone suggested the repo
valorant-skin-swapper-2026. - Solution: · I didn't clone it, I only read the description and the files. · I spotted the familiar signs of malware distribution: promising "free skin swaps", telling you to turn off your antivirus, downloading a password-protected archive. · I wrote up a report to send to GitHub.
- Lesson: recognise scams aimed at gamers. Never run code from an unknown source just because it promises free in-game items.
4.5. Cutting weapon images out of videos
- Goal: get a first-person in-hand weapon image for each skin, like
fov.png. - What I tried, in order: 1. An online background remover: it failed and cost 1 credit. 2. Frame differencing: take the parts that change between frames. 3. GrabCut (OpenCV): at first it picked a frame where the gun was aimed down sights (ADS). Fixed by picking the frame that matched a template (template fit). 4. A static pixel map: the background was still smeared, so I built a map of pixels that stayed still in ≥ 85% of frames, removed the background while protecting the core of the gun, then erased the hands in the leftover areas. 5. Jagged edges: smoothed with anti-aliasing at the edges.
- Result: the user felt it wasn't good enough and replaced it with a set of images they prepared themselves.
- Lesson: · Real-world image processing is far harder than the theory. · Know when to stop a costly direction and switch data sources.
4.6. The game stuttered: "why is Aim Lab smooth and mine isn't?"
Three rounds of optimisation, with the last one checking every single thing:
- A single WebGL canvas: before, the gun was a separate HTML image layer and the crosshair was a full-screen canvas. I merged the gun into the same 3D canvas (drawn in its own "HUD scene"), and the crosshair is now just a 96 px canvas in the middle.
- The room needs no lighting: the walls use
MeshBasicMaterial, and all the grid lines are merged into one pre-colouredLineSegments, so a single draw renders the whole room. - Dropped: anti-aliasing (MSAA), fog, tone mapping. Shaders use
mediumpprecision. - Targets share materials. Debris and bullet holes come from a pre-built pool, so no new memory is allocated per frame. The garbage collector doesn't have to run, so there are no short hitches.
- Mouse: use the
pointerrawupdateevent (it reports movement as soon as the mouse sends it, without waiting for a frame). Movements are accumulated and applied once per frame, and right at the moment you fire. - HUD: numbers are only rewritten when their value changes. Resolution is picked automatically once, during the countdown.
- Lobby effects stay as they are, but all of them are switched off during a round (the
body.in-roundclass). - Added an FPS counter and a 3D resolution option (100%, 75%, 50%).
Lesson:
- Graphics performance depends on the number of draw calls, the number of pixels to fill, and memory garbage per frame.
- Measure first, then fix.
- Trade away things that are "pretty but nobody sees" for smoothness.
4.7. Images from other sites wouldn't load in Discord
- Problem: an Activity in Discord can only load resources through its own proxy (
/.proxy/...). - Solution: · The server acts as a proxy for valorant-api.com and the Valorant wiki. · Lists are cached for 1 day, and images in an LRU cache of 400 images. · Large images are scaled down and compressed to WebP right on the server. · IDs are checked very strictly (a UUID, or
agentId~slotfrom an allowlist) so nobody can abuse the proxy to fetch arbitrary URLs.
4.8. The image library crashed the whole server
- Problem: during testing, when
@napi-rs/canvashit corrupt image data it took down the entire Node process (an error from native code, whichtry/catchcan't catch). - Solution: before decoding, check the file signature (magic bytes): PNG starts with
89 50 4E 47, JPEG withFF D8 FF, and WebP isRIFF....WEBP. - Lesson: native code doesn't follow JavaScript's error handling. Outside data has to be checked before it goes into a native library.
4.9. The settings panel overflowed the screen and the Save button couldn't be clicked
- First cause: the panel had no maximum height. Fixed with
max-height: calc(100dvh - 32px), aminmax(0, 1fr)grid row, and making the panel body scroll. - Second cause: on the Crosshair tab, the code that attached events to the General tab's buttons failed because the elements didn't exist yet, which also stopped the code that attached the Save button. Fixed by checking whether each element exists before attaching to it.
- Lesson: one JavaScript error at the top of a function can "kill" unrelated things at the bottom of it.
4.10. Uppercase headings spilled into the next column
- Solution: use a container query (the
cqiunit), so the font size follows the width of that column itself rather than the whole window.
4.11. Peek mode: the box has to actually cover the head
- Problem: in 3D, the sphere sits on the target plane. If the front face of the box sits right on that plane, half the sphere pokes out in front of the box.
- Solution: · Place the front face of the box 0.36 units in front of the target plane, towards the player, then scale it up by perspective (the ratio of distances) so that, from the player's eye, it covers exactly the rectangle the rules use. · The body of the box sits behind the plane, so the enemy head is always drawn on top of it. · Bullet holes from shooting the box land on the box face, not on the back wall.
- Lesson: perspective projection, and keeping what's drawn consistent with the scoring rules.
4.12. Callout coordinates on the minimap
- Problem: valorant-api gives in-game coordinates along with the factors
xMultiplier,yMultiplier,xScalarToAdd,yScalarToAdd. - Solution: · The correct formula swaps the axes:
x_minimap = y_game × xMultiplier + xScalarToAdd,y_minimap = x_game × yMultiplier + yScalarToAdd. · Checked by eye: Split's "Attacker Side Spawn" lands right in the spawn room. · The API's Vietnamese callouts are translated (Heaven becomes "Cầu"), so I always use the English ones, the way players actually call them.
4.13. The quiz was "too easy" and needed real in-map images
- Problem: the API has no screenshots from inside the maps.
- Solution: · Found that the Valorant wiki has screenshots for each callout, named after the callout (
Ascent_A_Main.png). · The scriptscripts/build-mapshots.jsuses the MediaWiki API to list images and then filters them: only 1920 px wide PNGs; skip patch comparison images, cards, minimaps and music; skip trailer frames named after agents. · Matched file names to callouts to get coordinates: 357 images, 274 of them with coordinates. · The repo only stores the list, and the images are fetched through the proxy. · Crops got blurry when zooming a 1280 px image 9×, so I kept the original 1920 px images and zoomed 6×.
4.14. Getting the real Valorant rank
- Problem: tracker.gg has no public API for Valorant. Riot's official API requires an approved production registration.
- Solution: · Use the HenrikDev API (unofficial, free key) with an
Authorizationheader and a 10-minute cache. · The Riot ID comes from Discord'sconnectionsOAuth scope. Discord's docs don't list theriotgamestype, so there's a fallback: the player typesTên#TAG(Name#TAG) themselves. · If the player declines the new permission, sign-in falls back to justidentify, so nobody gets locked out of the app.
4.15. The mouse got stuck when Discord wasn't fullscreen
- Problem: in a small window, Discord doesn't allow Pointer Lock. The game still turns with movement, but once the real cursor hits the edge of the frame there's no more movement, so you can't turn far enough to reach targets near the edge.
- Solution: when the pointer can't be locked, switch to cursor aiming. · The view stays still. · The vertical field of view widens automatically to fit the whole range. · The system cursor is shown as the player's own crosshair. · Shots are worked out by casting a ray (raycast) from the cursor position onto the target plane. · There's a line prompting you to go fullscreen.
4.16. Deployment problems
- Vite built against the old API: the dev API has to be restarted every time the shared rules or the server code change, otherwise the browser talks to the old version (this happened with the skin list).
- Vercel didn't auto-build one commit. While deploying by hand with the CLI, a temp folder cleanup command accidentally deleted the
.vercellink file, which made Vercel create an extra.deploy-tmpproject linked to the repo. · Lesson: after any step that fails midway, recheck the state before going on; cleanup commands must run after you're sure the main step is done. - Database schema changes: use numbered migrations based on
PRAGMA user_version. Each version adds oneALTER TABLEstatement (theriot_idandshared_atcolumns), runs inside a transaction, and loses no existing data. - A server shared with other bots: never run
docker prune, never touch other services' containers and ports. Only rebuild my own container.
---
5. Computing skills I picked up
5.1. 3D graphics and performance (three.js, WebGL)
- A perspective camera with a fixed 103° horizontal field of view, with the vertical angle computed from the aspect ratio.
YXZrotation order for an FPS camera, with limits on looking up and down (clampView).- Casting rays (raycast) from the screen onto a plane to find the aim point.
- Draw calls, merging geometry (
mergeGeometries), shared materials, object pools. - Basic, Lambert, Phong and Standard materials: what each one costs.
- Dynamic resolution (pixel ratio),
devicePixelRatio, MSAA. - Drawing two scenes in one frame:
autoClear = false,clearDepth, an orthographic camera for the HUD. - Textures: sRGB color space, mipmaps, scaling images down through a canvas before sending them to the GPU.
5.2. Input handling
- The Pointer Lock API and the
unadjustedMovementoption (which skips the operating system's mouse acceleration). pointerrawupdateversuspointermove, and why you accumulate movement and process it once per frame.- Mouse counts versus CSS pixels, DPI, eDPI, Valorant's sensitivity formula.
- Fallbacks for when the browser or the iframe refuses permission.
5.3. Anti-cheat and determinism
- An authoritative server, replaying from the event log.
- A seeded PRNG (mulberry32), with the target spawn schedule depending only on time and previous hits.
- Statistical anomaly detection (gaps between hits, hit rate, number of movement samples).
- The daily challenge seed is generated with HMAC from a secret, so it can't be predicted.
5.4. Backend and databases
- The SQLite built into Node (
node:sqlite), WAL mode, indexes for leaderboard queries. - Migrations based on
user_version. - A plain HTTP API with no framework: routing, gzip for large responses, cache headers (
immutable,no-store). - Proxying and caching: TTL, LRU, serving the old copy when the source fails (stale-on-error).
- Strict input validation: only allowing values from an allowlist, normalising settings.
- Operations done only once (idempotent), like "share exactly once" with
UPDATE ... WHERE shared_at IS NULL.
5.5. Discord
- The Embedded App SDK:
authorize, exchanging the OAuth code for a token only on the server (keeping the client secret),authenticate. - OAuth scopes:
identify,connections. User install versus server install. - The Activity's proxy URL and how to map
/api. - discord.js: slash commands, registering commands globally versus per server, embeds, sending files, permissions (Send Messages, Attach Files, Manage Roles), app-owned emoji.
5.6. Image processing
- Drawing card images on the server with
@napi-rs/canvas: fonts, gradients, text that shrinks to fit its box. - WebP encoding, detecting file formats by magic bytes.
- OpenCV: GrabCut, frame differencing, masks, edge anti-aliasing.
- ffmpeg: extracting frames, resizing images.
5.7. Outside data
- Public REST APIs (valorant-api.com), the MediaWiki API, HenrikDev's OpenAPI spec.
- Reading the schema to know the exact data fields instead of guessing.
- Fuzzy name matching (string normalisation) to join two data sources.
- Copyright: Riot's fan content policy, the wiki images being game screenshots, use only in a free project.
5.8. DevOps
- Docker and docker compose (profiles, healthchecks, volumes, env_file).
- Tailscale Funnel to expose a service to the internet without opening router ports.
- Deploying with
git archive | ssh tar -x. - Vercel auto-building from GitHub, and deploying by hand with the CLI.
- Database backups, service health checks, reading logs.
- Working safely on a shared server.
5.9. Testing
node:test, mockingfetchand Discord so tests don't need the network.- Replay tests for every mode.
- Type checking with TypeScript (
tsc --noEmit). - Real testing with an automated browser: taking screenshots, reading the console, simulating clicks.
- The limits of an automated browser: it runs at 1 FPS so it can't measure smoothness, and simulated clicks can't request pointer lock.
5.10. Product design
- The first page has to answer "what is this" and "where do I click" right away; everything else comes later.
- A single accent colour, a dedicated display typeface, chamfered corners in the style of a game client.
- Effects in the lobby to make an impression; during a round, smoothness comes first.
- Listen to feedback and be willing to cut: robot heads, map backdrops, self-cut skins, 3 old interface themes.
---
6. What's still left
- Test the "Your Valorant" card with a real HenrikDev key on Discord.
- Keep an eye on whether Discord returns the Riot account through the
connectionsscope. - Retune the score ranges for Peek ranks once there's real data.
- Reconsider whether rounds played in cursor aiming mode (small window) should count towards rank.
- Find out why Vercel didn't auto-build commit
499c314.