Skip to content
HN On Hacker News ↗

Shipping OpenStrike: a Counter-Strike-Shaped FPS on a 2004 Handheld · PocketJS

▲ 53 points • 15 comments • by itvision • 4mo ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully AI-generated

99 %

AI likelihood · overall

AI
0% human-written 100% AI-generated
SEGMENTS · HUMAN 0 of 6
SEGMENTS · AI 6 of 6
WORD COUNT 1,809
PEAK AI % 99% · §4
Analyzed
Jul 18
backend: pangram/v3.3
Segments scanned
6 windows
avg 302 words each
Distribution
0 / 100%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,809 words · 6 segments analyzed

Human AI-generated
§1 AI · 99%

Today we are releasing OpenStrike — a single-player, Counter-Strike-shaped FPS that plays the classic GoldSrc-era maps: bots, tracers, recoil, round flow, and a JSX HUD. The round rules are TypeScript. The HUD is a Solid app. And the whole thing holds a locked 60 FPS on a 2004 Sony PSP, shipped as one file you can drop on a Memory Stick. Native 480×272 output of the shipping build, shown at 2× — this frame and every other screenshot in this post is the PSP executable's own framebuffer, captured in the emulator our byte-exact tests run on.OpenStrike is open source at pocket-stack/open-strike. It is the first game built on the Pocket runtime family — the architecture underneath PocketJS — and it exists to prove a claim: that "the web stack's ergonomics, without the web stack's machinery" scales past UI, to a real-time 3D game, on hardware that predates the iPhone. This post is written for people who ship JavaScript for browsers and have never touched a games console. No embedded or graphics background is assumed. It is the full story: what the machine actually is, how TypeScript ends up inside it, what a 1999 map format has to do with your bundler, how you draw anything without shaders, and where the milliseconds went. The machine in question The PSP-1000 has a single MIPS CPU core at 333 MHz. In-order execution, no speculative anything, and — this matters most for us — no JIT for JavaScript, so every closure runs interpreted, every frame. It has 32 MB of RAM, of which user programs see 24 MB. The browser tab you are reading this in has a larger JavaScript heap than this machine has memory, total. The GPU — Sony calls it the Graphics Engine — predates programmable shaders. You cannot upload a vertex shader or a fragment shader; there is nothing to upload to. It is a fixed-function pipeline: it can transform triangles by a matrix, interpolate a color across each one, sample a texture with bilinear filtering, depth-test, blend — a fixed menu, configured by registers, feeding a 480×272 screen out of 2 MB of video memory.

§2 AI · 99%

And there is no operating system in the sense you mean. No processes, no virtual memory, no dynamic linker, and nowhere for console.log to go. The whole game — Rust engine, JavaScript interpreter, the JS bundle, the map — links into a single executable (an EBOOT.PBP, the PSP's .exe), and when it runs, it is handed the machine. When it crashes, the machine crashes. That is the deploy target. Here is what we deployed. One product, two machines OpenStrike is split along a line web developers will recognize instantly — it is the native-app split, transplanted: the engine is Rust, the product is JavaScript. The Rust side (openstrike-core plus the Pocket3D renderer) owns everything that must never miss a frame: player movement and collision, bot AI, bullets, and drawing the world. The JavaScript side owns everything that makes it this game rather than some game: rules.ts is the round flow, scoring, and the weapon and bot tuning tables; hud.tsx is the entire HUD — health, ammo, crosshair, score — an ordinary Solid component tree styled with Tailwind classes, running on PocketJS. The base game grants itself no privileges a mod wouldn't have: change rules.ts and you have made a mod. The JS engine embedded in the executable is QuickJS — Fabrice Bellard's full ES2023 engine that compiles to a few hundred kilobytes. QuickJS is to this PSP what V8 is to Node: the host hands it a strike API surface and a ui API surface, and the same openstrike.js bundle boots against them on every target. Every target — plural, and that is the release's real headline:

openstrike.js + openstrike.pak the product — TypeScript, one bundle, byte-identical on both machines rules.ts · round flow, scoring, weapon + bot tables hud.tsx · the HUD — a Solid app, Tailwind classes QuickJS

§3 AI · 99%

guest embedded via rquickjs strike + ui surfaces state snapshots ↓ · commands ↑ openstrike-core — the simulation no_std Rust, shared verbatim pocket3d · wgpu renderer Metal / Vulkan / DX12, any resolution your laptop cargo run -p openstrike QuickJS, compiled to MIPS interpreter only — no JIT on this CPU strike + ui surfaces same vocabulary, mirrored field for field openstrike-core — the simulation the same crate, recompiled for MIPS pocket3d-gu · sceGu renderer fixed-function GE, 480×272, 2 MB VRAM a 2004 Sony PSP one EBOOT.PBP — engine, JS, and map inside facts flow down (state snapshots + events) · intent flows up (commands) · the guest never blocks the simulation The tick order is fixed and worth internalizing, because everything later hangs off it. Sixty times a second: every 16.7 ms (one vertical blank) pad → SimInput buttons + analog stick sim.tick(dt) movement, bots, bullets Rust strike.__dispatch(state, events) one call into QuickJS JS ├─ rules.ts reacts queues commands └─ hud.tsx re-renders only the bindings that changed drain commands → core setPhase, configureWeapon… Rust draw PVS → batches → GEFacts flow down as a plain state snapshot (hp, ammo, phase, aliveBots, …) plus an event batch (hit, playerDied, roundReset). Intent flows up as queued commands. JavaScript is consulted exactly once per frame and can never stall the simulation — the same one-crossing-per-frame discipline PocketJS already enforced for UI, now owning a game's rules. A bundler for 1999's geometry Now the 3D part, from zero. A GoldSrc-era map — the format behind Half-Life and the original Counter-Strike — is not a soup of triangles.

§4 AI · 99%

It is a BSP file: the level's polygons stored pre-organized into a binary space partitioning tree, a 1990s data structure that recursively slices the world with planes until every position falls into a convex cell called a leaf. The format's killer feature is the PVS — the Potentially Visible Set. For every leaf, the file stores a precomputed, compressed bitset of every other leaf that could ever be visible from inside it, through any door, over any crate, at any angle. Computing it took the level designer's machine minutes, once, in 1999. Consuming it takes microseconds, forever. If you want this in web terms: the PVS is occlusion culling as a lockfile. All the expensive thinking happened offline, and the runtime just looks up the answer. Standing in the dust2 courtyard, the renderer never even considers the tunnels' geometry — not "draws it fast," but never touches it. We kept that 1999 spirit and extended it with a modern move: treat the console like a deploy target and put a compiler in front of it. The PSP cannot afford to parse a BSP file, decode textures, or reshape data at load time — that costs RAM for intermediate copies and CPU we would rather spend on bots. So OpenStrike's build step runs pocket3d-cook on your laptop, which reads the map plus its .wad texture archives and emits a .p3d: a file whose bytes are exactly what the PSP's GPU wants to read, in the exact layout, alignment, and byte order.

de_dust2.bsp BSP v30 — GoldSrc, 1999 + .wad texture archives (from your own copy of the game — map data is not redistributed) pocket3d-cook build time, on your laptop · dice faces on a 32-unit grid ·

§5 AI · 99%

lightmaps → vertex colors · quantize positions to i16 · resample to pow², bilinear · CLUT8 + swizzle + mips · pack PVS + collision hulls every byte laid out exactly as the GE's DMA engine will read it de_dust2.p3d — 3.8 MB WVTX 58k verts × 20 B WIDX u16 indices WBAT draw batches WTEX swizzled CLUT8 + mips WVIS the PVS WCLP collision hulls WENT spawns, sun, bounds zero parsing, zero copies at load time linked into the EBOOT — the GPU reads these bytes in place; “loading a level” is one cache-writeback call "Baking" is the word for moving runtime cost to build time, and the cooker bakes aggressively:

Lighting is baked into vertex colors. GoldSrc maps ship lightmaps — little per-surface shadow textures. The GE could multitexture them, but every texture unit costs bandwidth we would rather not spend. So the cooker dices each polygon into a grid and samples the lightmap into the vertices' color channel. The GE interpolates colors across triangles for free, so sunlight, shadow edges, and that orange dust2 glow cost literally nothing per frame. It is inlining, for light. Positions are quantized to 16-bit integers. Half the memory and half the GPU-read bandwidth of floats. (This unlocked the port's favorite piece of hardware archaeology: in 3D mode the GE silently normalizes integer vertices — divides them by 32768 — a behavior documented nowhere. The counter-move is to scale the world back up by 32768 in the model matrix. We confirmed it by reading the emulator's source code, which on this platform is what developer documentation looks like.) Textures become CLUT8: 8-bit indices into a 256-color palette — 4× smaller than RGBA, which is how a whole map's texture set fits alongside everything else.

§6 AI · 99%

They are resampled to power-of-two sizes (a hard GE requirement), swizzled — reordered into the tile pattern the GPU's texture cache wants, roughly "structure-of-arrays, for texels" — and given full mip chains (pre-shrunk versions for distant surfaces).

The payoff for all this build-time obsession is the load screen we don't have: the .p3d is linked into the executable's read-only data, and the GE renders directly from those bytes, in place. No file I/O, no decompression, no upload step. The one concession to hardware reality is a single cache-writeback call at boot, because the GPU reads memory behind the CPU cache's back. Level loads are instant because nothing loads. If you have used PocketJS, this is the same ideology that compiles Tailwind classes to a binary style table and bakes fonts to atlases — the device never parses, it only consumes — applied to an entire 3D world. The texture glow-up, or: the machine hides nothing Midway through the port we noticed the textures looked muddier than 1999 deserved, and the investigation is a nice showcase of how unforgiving — and how legible — this hardware is. Three independent causes, all in the pipeline above: The same crate, before and after, 3× crop of the PSP framebuffer. Left: nearest-neighbor resampling, round-down sizing, default mip selection. Right: bilinear palette-aware resampling, round-up sizing, −1.0 mip bias, and a 2× denser light grid.First, non-power-of-two textures (a 96×96 crate face, say) were being stretched to GPU-legal sizes with nearest-neighbor sampling, which duplicates texel columns in a visibly irregular rhythm. The fix is a bilinear resample — but through a palette: sample in RGB, then re-quantize each result to the texture's own 256 colors. Second, sizing policy rounded to the nearest power of two, so a 320-wide texture dropped to 256 and threw away native detail; it now always rounds up (the GE's ceiling is 512).