A night forest with one model file: what Lullwood ships instead of assets
Lullwood ships one 3D model file, a 226 KB .glb that spells the leaderboard record-holder’s name in the sky. The forest, the wolves, bears and lions, the sky and every sound are built in code. Measured in a browser, a cold first load of the game fetched 676 KB, and that one model was 153 KB of it.
We build Lullwood, a free browser horror game, in three.js and Next.js. This post counts what the repository ships, what a browser downloads, and what each claim rests on. Code references are to our release branch at commit b03da13, with line numbers for that commit. Byte counts are bytes; “KB” here means 1,000 bytes.
What the repository ships
- Models: one.
public/models/letter-balloons.glbis 226,360 bytes. It holds 26 meshes (the letters A to Z), 8,224 vertices, 12,435 triangles, and no textures. It is a third-party model under CC BY 4.0, trimmed by us and credited on the Lullwood credits page. - Audio files: none.
git ls-fileslists no.mp3,.ogg,.wav,.m4a,.aac,.opusor.flac. - Texture loaders: none. A search of the engine, library, component and app code finds no
TextureLoader. The one image-file use is 30 country-flag PNGs (56,449 bytes in all), fetched one per country on first use and drawn onto the balloon’s canvas (forest-engine.jsL585-594). - Video: one.
public/death.mp4is 60,635 bytes, in the page as apreload="auto"element (components/GameCanvas.tsxL1088). - Code: the engine is
engine/forest-engine.jsat 595,103 bytes plusengine/tuning.jsat 30,599.
“Ships” is slippery here. public/ holds 46 files and 26,055,552 bytes in git, and nine marketing PNGs (press images, screenshots and one controls diagram) make up 25,699,407 of them. The game never requests those. What matters for a player is the next section.
How we measured
- Repository counts:
git ls-files,git grepandgit showagainstorigin/release/nextatb03da13, run on 2026-11-02. The live site builds frommain(572d8cfthat day).git diff --stat main release/next -- engine lib components public app/page.tsx app/layout.tsx package.jsonprinted nothing, so the game code is identical. - First load: Chrome for Testing 151 driven by Playwright, empty cache, a 1280 by 720 desktop window, on
https://www.lullwoodgame.com/. We summedencodedDataLengthfrom the DevTools Network domain (bytes on the wire after compression, response headers included). The window runs from navigation until 16 seconds after the click that starts play, with a W key held for 12 of them. Nothing more downloaded after the click. - Repeats: three runs, 675,829, 675,844 and 675,856 bytes. We quote the first. It is one browser on one network on one day, and it is not a mobile measurement.
The first load of the game, labelled
Lullwood has no separate game page: the game and a short text page about it share /. That makes it easy to quote the wrong number. Fetching the home page with curl and adding the script and style files its HTML names gives about 201 KB (9,332 bytes of document and 191,751 of files). That is the page. The engine, the model and the video load afterwards, so the figure for the game is the browser one.
Game route, cold, browser: 675,829 bytes in 23 requests.
- Engine and three.js, one script: 215,807 (795,397 decoded)
- React, Next.js and interface scripts, ten files: 206,697
- The model,
letter-balloons.glb: 153,478 (22.7% of the load) death.mp4: 61,320- Document, stylesheet, icons and one resized screenshot in the page below the game: 23,596
- Cloudflare analytics beacon and its report call, both from a third party: 10,955
- One flag PNG: 2,780
- Leaderboard and telemetry calls: 1,196
The game itself (engine, model, video, one flag and two API calls) is 434,581 bytes, 64% of the load. The model and the video are the only parts of it that are not code, and together they are 214,798 bytes, 32%.
The awkward part is the model. The glTF loader runs during engine setup (forest-engine.js L566, inside init), so every player downloads 153 KB of letters before anyone has looked at a leaderboard. If the load fails, the game draws the nickname on a canvas instead (L575). We did not weigh whether that trade is right, and we are not claiming it is.
The forest is code
A tree is three shapes. trunkGeo is a six-sided CylinderGeometry (L712) and two ConeGeometry canopies stack above it (L713-714). Each map chunk builds one InstancedMesh for each shape (ensureChunk, L1211-1213), so a chunk’s trees are three instanced meshes, not a model per tree. The default configuration asks for up to 5,200 trees (engine/tuning.js L86).
Cover is the same idea. A log is a BoxGeometry, a rock a DodecahedronGeometry and a bramble an IcosahedronGeometry (L760-762), one instanced mesh per kind per chunk (L1324). The throwable stones reuse the rock geometry (L781-783). In all, new THREE.InstancedMesh appears at five places in the engine: L783, L1211, L1212, L1213 and L1324.
Textures are the same story. CanvasTexture appears five times (L463, L495, L520, L531, L1800). The sky backdrop is a 4 by 512 pixel canvas painted with a three-stop gradient (L458-463), and the radio-mast beacon glow is a 64 by 64 radial gradient (L1793-1801).
The predators are spheres, cylinders and cones
All three species come from one function, makePredator (L2305-2367). It calls only SphereGeometry, CylinderGeometry and ConeGeometry. A chest, midriff and haunch are three overlapping spheres; the neck, snout, legs and tail are cylinders; the ears are cones or small spheres, depending on the species; the lion’s mane is a ring of twelve small spheres (L2332). What makes a bear a bear is a handful of numbers in a spec table (height, length, width, whether it has ears or a mane), not a different model. Three of each species are built when the engine loads (L2372), nine predators in all.
We checked the other direction too. The only loader import in the engine is GLTFLoader (L29), and its only call is the balloon letters at L566. No predator, tree or rock goes through it.
The sound is code, too
There are no audio files, so the audio is written into the Web Audio graph. noise() (L4145-4150) fills a buffer with Math.random(), and the wind, insect and rain beds are that buffer looped through filters (L4195-4211). The low drone is three sine oscillators at 55, 82.5 and 110 Hz (L4215-4217). The chase cue is a sawtooth cluster at 55, 77.78 and 110 Hz over a pulsing 110 Hz sine (L4224-4226). In the engine, createOscillator appears on 53 lines and createBufferSource on 19.
What this does not show
We measured bytes, not time. We have no frame-rate, load-time or memory comparison against an asset-based game, so this post makes no speed claim and no claim that code-built art is better. Drawing a wolf from spheres costs authoring effort and looks like what it is. The claim is narrow: the files a player downloads for the forest, the animals and the sound are zero, and the one model we do ship is the second-largest thing in the load.
To see what 676 KB buys, play Lullwood free in the browser. For how the wolves behave once they are on screen, read how to survive wolves in Lullwood.