Maps are Blender files
There is no level editor to install and no SDK to learn. If you can model a room in Blender and name a handful of objects, you can play a match in it a few minutes later. This page is the whole workflow, start to finish.
Four steps to a playable map.
Copy the example map, model, name a few objects, run one command. Everything further down this page is detail you can pick up when you need it.
1. Open the example map
data/maps/example_map/map.blend ships with the game
and uses every convention on this page: spawns, bomb zones, kill zones, the sun, a sky dome,
probes, ambience markers, surface tags and a material setup that exports cleanly. Save As it
into your own folder and you start from a map that already works.
2. Model it
Any Blender 4 or 5 file. Walls are walls, floors are floors, and what you model is what players collide with - there is no separate collision mesh to build and no compile step. Blender is Z-up and the game is Y-up; the exporter handles that for you.
3. Name a handful of objects
Spawn markers, two bomb zones and one hdri_default
probe are the minimum. Everything else - sounds, surfaces, foliage, shaders - is opt-in, and
all of it is done by naming the object.
4. Run one command
Point the client at the .blend. It exports, bakes lighting with Cycles, captures reflection probes, starts a server in-process and drops you into your own map. Add bots and you are playing a real match in it minutes after you started.
Blender file in, playable match out.
One command exports, bakes, captures probes, starts a server and connects you. There is no build step to run first and no editor to learn.
./client --host-map data/maps/de_house/map.blendThat single command does, in order:
- 1. Export
- The .blend is exported to glTF through the shipped export script - sun direction, spawns, zones, surface tags and tangents all come across.
- 2. Bake
- A full Cycles lightmap bake at 128 samples across every CPU core and your GPU. Minutes on the first run, instant on every run after that.
- 3. Capture probes
- If any HDRI probe face is missing, the client captures them from the live scene before letting you play - a few seconds with the HUD hidden.
- 4. Host
- A server starts inside the client process on port 27015 and the client connects to it. Same server code a dedicated box runs, so everything is exercised exactly as it will be in a real match.
Bake modes
- --skip-bake
- No Cycles bake. Launches in seconds on sun and ambient light only. This is the mode to live in while you are moving walls, placing spawns and testing sightlines.
- --fast-bake
- A one-sample Cycles bake: noisy, but it shows you the shape of the baked lighting in seconds rather than minutes.
- no flag
- The ship-quality 128-sample bake. Run it once the geometry has settled, or before you share the map with anyone.
- --no-bake-gpu
- Bake on CPU only - useful when the GPU is busy with something else.
- --bake-workers N
- Override the automatic worker count.
- --force-export
- Ignore the bake cache for one run.
The bake cache
Every successful export is recorded in ~/.cache/proaim/map_bakes.json against the .blend's path, modification time and bake mode. Re-running on an unchanged file at the same mode short-circuits the whole pipeline and connects instantly. Saving the .blend invalidates that one map; changing the export script invalidates every map.
Already have a .glb? --host-map data/maps/<name>/map_baked.glb skips Blender entirely.
One folder per map.
The folder name is the map's identity. Put the .blend inside it and everything else is generated beside it.
proaim/
client, server, README.txt the shipped binaries
data/
maps/
example_map/ the map that ships with the game
map.blend your source of truth
map_baked.glb regenerated
map_baked.png regenerated lightmap atlas
hdris/ regenerated probe faces
de_house/ your map
map.blendThe folder name is the map's identity, and the files inside it are generic. The exporter refuses to run on a .blend that lives anywhere else, because that is where the map's name comes from.
Only the .blend is yours to keep - everything else regenerates. If you link external image textures, either pack them (File → External Data → Pack Resources) or keep them beside the .blend so their paths still resolve when the exporter runs.
Naming is the scripting language.
There is no scripting layer and no entity editor: an object's name is what tells the game what it is. This is the whole vocabulary.
- spawn_red_0, spawn_blue_0, ...
- Team spawn points. The marker's origin is the player's feet.
- bomb_zone_red / bomb_zone_blue
- The bomb site a team defends. The attacker plants on the defender's zone, exactly like Ts planting on a CT site - so bomb_zone_red sits near red's spawn.
- bomb_marker_red / bomb_marker_blue
- Author-time visual markers for those zones. Hidden at runtime.
- kill_zone
- An instant-death volume. The bottom of the map, or anywhere else a fall should end.
- no_col*
- Drawn, but nothing collides with it - decorative grass, rubble, clutter.
- invisible_col*
- Collides, but is never drawn - clip boxes, knee walls, ceiling caps.
- sky_dome*
- The skybox. Never collides, never bakes, and its authored material is what reflects in chrome.
- sun_main
- The Sun lamp whose direction, colour and energy are baked into the exported map.
- hdri_default
- Required, exactly one. The map-wide reflection probe.
- hdri_<easing>
- Optional per-zone reflection probe; the cube's box is where it takes over from the default.
- ambience_<db>_<easing>_<stem>
- A looping ambient sound zone.
- material_<tag>_*
- Surface tag driving both bullet-impact and footstep sound banks.
- movement_grass_* / movement_foliage_* / movement_tree_*
- Opt this mesh into wind sway at one of three scales.
- shader_<stem>
- Swap this mesh onto a custom shader from data/shaders/<stem>.ron.
Prefixes compose: no_col_shader_ocean1 is both collision-free and running the ocean shader. Blender's .001 / .002 duplicate suffixes are handled for you.
Marker meshes should not light the map. Per marker, in Object Properties → Visibility → Ray Visibility, uncheck everything except Camera.
The four objects a match needs.
Spawns for both teams and a bomb zone each. Get the heights and the ownership right and the map is playable; get them wrong and every round starts badly.
- Height matters
- A spawn marker's origin is where the player's feet appear. Set its Z to exactly the floor surface: too low and players start inside the floor, too high and every round begins with a drop.
- Which zone is whose
- bomb_zone_red is the site red defends and blue attacks. Put it near red's spawn and bomb_zone_blue near blue's, then check it in game - halftime swaps the sides, so both have to work from both ends.
- Plantable surface
- The plant happens where an attacker can stand inside the zone, so a site needs floor a player can actually stand on rather than a volume floating over scenery.
- Count them
- Give each team as many spawn markers as the biggest match you expect - five a side is the default team size.
# Snap every spawn marker to the floor height, in Blender's Scripting tab
floor_z = 0.7
for obj in bpy.data.objects:
if 'spawn' in obj.name:
obj.location.z = floor_zLight it in Blender, play it in the game.
Lighting is a full Cycles bake, so the room you lit is the room you play in. The only rule is to light it the way the pipeline expects.
- One sun
- Name your Sun lamp sun_main. Its direction, colour and energy are read at export and become the game's directional light, so the shadows you set up in Blender are the shadows you play in.
- The lightmap
- Everything static is baked to a lightmap atlas with Cycles and shipped alongside the map. That is where bounce light, soft shadow and colour bleed come from.
- What is not baked
- Foliage that sways and any marker mesh is excluded automatically - moving geometry cannot be represented by a static bake.
- Sky texture
- Do not light the map from a World Sky Texture: it would add a second phantom sun to the bake, and the exporter strips it. Light with sun_main and let the sky_dome mesh carry the look.
Iterate with --skip-bake, then bake properly when you want to actually look at the place. A full bake is minutes; it is not something to sit through between wall moves.
What the shiny things reflect.
Reflections come from cubemaps captured inside your own level at runtime. One probe is mandatory; the rest are for rooms that light differently.
Anything shiny - chrome, glass, polished metal - reflects a cubemap captured from a probe you place in Blender as a small named cube. You never render these in Blender: the game captures them from the live scene, including your sky dome, the first time the map runs.
- hdri_default (required)
- Exactly one per map. A one-metre cube at roughly eye height in the most average open part of the level. Its capture is what reflects everywhere the player is not inside a zone probe.
- hdri_<easing> (optional)
- Any number of zone probes. The cube's box is where that probe takes priority - a dim warehouse against the sunlit street, a neon basement against a daylight roof.
- Easing
- soft is a smoothstep and the usual choice; medium is linear; hard is quadratic, for sharp room boundaries; instant is binary in space and only smoothed over 200 ms in time.
- Duplicates
- Two probes can both be called hdri_soft - Blender's .001 suffix keeps their captures distinct.
How capture works
- First launch
- The HUD hides for a few seconds while the client visits each probe position and photographs all six directions. The faces land in data/maps/<name>/hdris/ and play resumes.
- Later launches
- The faces are read straight off disk. No capture pass, instant connect.
- Re-capturing
- Moved a probe, renamed one, or changed the world lighting? Delete data/maps/<name>/hdris/*.jpg and launch again - the missing faces trigger a fresh pass.
- --capture-ibl
- Forces a capture pass even when the faces exist, then exits. This is the batch-script path; interactive play relies on the automatic trigger.
- --capture-ibl-equirect
- With the above, also writes a 2048x1024 equirectangular preview per probe - the easiest way to eyeball what a probe actually saw.
- --debug-ibl
- Spawns a chrome ball a metre in front of the camera that reflects the active probe. Sky on top, ground below, geometry around the sides means it is working.
A grey or part-black ball means one of three things: the map has no hdri_* marker at all, the player is in a zone probe's box with no hdri_default to fall back to, or a capture was interrupted - delete the JPEGs and relaunch.
What survives the trip through glTF.
Maps travel as glTF, and glTF is narrow. Knowing exactly what it keeps saves you from materials that look right in Blender and flat in game.
Maps travel as glTF, and glTF understands one thing: a Principled BSDF with image textures on its inputs. Build materials that way and they arrive intact. Anything cleverer is dropped silently, which is the one place this pipeline can waste your afternoon.
Comes through
- Principled BSDF with constant Base Color, Metallic, Roughness and Normal.
- Image Texture nodes wired straight into those inputs - albedo in sRGB, roughness and metallic as Non-Color, normal through a Normal Map node.
- One UV map per mesh (the active one).
- Alpha blend and alpha clip through the base colour's alpha channel.
Does not
- Procedural nodes - Noise, Voronoi, Checker, Brick, Gradient, ColorRamp and Math chains, Bump, Displacement. Bake them to an image first (Shader → Bake).
- Several BSDFs mixed together. One Principled BSDF per material.
- Node groups wrapping any of the above.
- Geometry, Tangent, Texture Coordinate, Light Path and AO node tricks.
Rule of thumb: reduce a material to "Principled BSDF plus image textures" in the Material Preview viewport. If it still looks the same, it will export the same. Whatever changed is what glTF was going to throw away.
Leave Emission Color black and Emission Strength at 0 on walls, floors and props. Emission is added on top of lighting rather than shaded by it, so an emissive wall comes out as a flat uniform colour. If something is too dark, brighten its base colour.
Seeing metallic and roughness for real
A grid of PBR spheres sweeping both controls ships with the game - metal along the top, plastic along the bottom, mirror-smooth on the left to fully matte on the right. Set your own values by matching them.
./client --test-scenario material_showcase --exit-after 10Thirteen tags, two sound banks each.
Tag a surface and it gets its own bullet impacts and its own footsteps. In a game where sound is information, this is not decoration.
Prefix a mesh with material_<tag>_ and it drives two separate sound banks: what a bullet sounds like hitting it, and what a player sounds like walking on it. Anything after the tag is yours - material_wood_floor_02 is fine. Untagged meshes fall back to concrete and log a warning at load so you can find them.
concrete
The default. Hard sharp thwack, crisp footsteps. Poured floors, rough walls, sidewalks, stone rubble.
dirt
Soft low thud, earthy footsteps. Ground, dry paths, flower beds.
wood
Splinter crack, hollow plank footsteps. Planks, pallets, doors, tables, crates.
glass
Crystalline shatter, brittle crunch underfoot. Windows, display cases, bottles.
metal
Ringing ping with sustain, heavy deck footsteps. Armour plate, I-beams, thick lockers.
steel
Thin sheet - brighter and ringier than metal. Catwalks, grates, ducts, panels.
tile
Ceramic: sharp crack, smooth footsteps. Bathrooms, kitchens, indoor corridors.
carpet
Muffled to near-silent. Rugs, carpeted rooms, soft furniture, foam mats.
sand
Gritty shuffle. Beaches, deserts, sandbag tops, bunker floors.
mud
Wet squelch. Swamps, rain-soaked trenches, river banks.
grass
Soft rustle. Lawns, meadows, groundcover.
foliage
Brush and leaves for impacts; footsteps fall back to grass, because you walk on the ground under a bush rather than on the bush.
cardboard
Hollow crumple. Boxes, packaging, thin partitions.
The tag has to match the whole token: material_metal_* is metal, but material_metallic_*, material_sandbag_* and material_gravel_* are all untagged. Case is ignored and .001 suffixes are stripped.
The example map parks a row of thirteen colour-coded cubes, one per tag, between red spawn and the kit row. Walk on each to hear its footsteps; shoot each to hear its impacts.
Rooms that sound like somewhere.
A named cube is a zone of looping sound at a stated loudness with a stated falloff. One marker plays everywhere as the bed.
A mesh cube named ambience_<db>_<easing>_<stem> becomes a zone of looping sound. The cube's box is the zone, the cube itself is deleted at load, and the audio comes from data/sounds/ambience/<stem>.ogg.
ambience_-40_soft_rain rain.ogg at -40 dBFS, smoothstep edges
ambience_-24_hard_generator generator.ogg, strong core, sharp boundary
ambience_-45_default_wind wind.ogg everywhere on the map (exactly one)- <db>
- A signed target loudness in dBFS between -60 and 0. The source file is normalised to hit it, so how hot the .ogg was cut does not matter. Ambience belongs around -35 to -50: it is a bed under the game, not part of it.
- <easing>
- soft is a smoothstep, medium is linear, hard is quadratic, instant is binary. default means "play everywhere" - exactly one marker per map carries it, and zones fade against it.
- <stem>
- Everything after the third underscore, underscores included. A missing .ogg is a hard error at load rather than a silent map.
Walking through a doorway crossfades the zone against the map-wide default over a couple of hundred milliseconds, so there is no click and no sudden swap.
Plants that move at their own scale.
Three presets, chosen by name, driven by a vertex shader. Grass flutters, bushes sway, trees roll.
Three presets, picked by name, sway a mesh in the wind. The frequencies differ because real plants differ: a half-metre blade of grass flutters far faster than a five-metre tree.
- movement_grass_*
- About 2.4 Hz, the whole blade moving, 0.35 m of wave height. Grass tufts, wheat, reeds.
- movement_foliage_*
- About 1.3 Hz with medium tip emphasis and 1 m of wave height. Bushes, ivy, hedges.
- movement_tree_*
- About 0.55 Hz with a steep mask, so the canopy moves and the trunk stays put. 5 m of wave height.
Modelling rules
- Origin at the base
- The shader uses local height as the sway mask: zero at the origin, full amplitude at the wave height. A mesh whose origin is not planted will sway around the wrong pivot.
- Two-sided already
- Backface culling is off for these, so single-plane grass cards and flat leaf cards work without duplicating and flipping them.
- Share materials deliberately
- Your Blender material assignments are kept as-is; the exporter only prefixes their names. Ten thousand grass tufts sharing one material collapse into one draw call, and a hundred separate materials are a hundred draws. Share for fields, split for variety.
- Not baked
- Swaying meshes are excluded from the lightmap and lit by sun and ambient only - a static bake cannot represent moving geometry.
Your own materials, reloaded as you save.
Any mesh can be routed to a shader file, and shader files recompile while the game is running.
Name a mesh with shader_<stem> and it renders through data/shaders/<stem>.ron instead of the default map material. Drop your own .ron in beside the shipped ones and it works the same way.
data/shaders/map_default.ron the default for every map mesh
data/shaders/glass.ron translucent glass
data/shaders/ocean1.ron animated water
data/shaders/sky1.ron animated sky-dome gradient
data/shaders/hologram.ron animated scanline hologram
data/shaders/rim.ron rim-light highlight
data/shaders/toon.ron flat-shaded cel lookHot reload
- File watcher
- Every save to data/shaders/*.ron recompiles in place while the game keeps running. A line in the terminal confirms each reload.
- F10
- Recompiles every shader the scene is using, in under a millisecond. Use it when your editor's save produces filesystem events the watcher misses.
- Errors
- A parse or compile error stops the game with the file path and the message, rather than quietly keeping the last shader that worked. Fix, save, and the next reload picks it up.
For pure shader work, host the example map with the bake skipped - a clean stage that loads in seconds: ./client --host-map data/maps/example_map/map.blend --skip-bake
Fill it with bots before anyone else sees it.
A map is a set of timings and angles. The only way to find out what yours are is to play a real match in it.
Walking a map alone tells you almost nothing. Bots fill it out: they path, they take angles, they trade, and they will find the sightline you did not know you built.
./client --host-map data/maps/de_house/map.blend --skip-bake --host-bots 5- --host-bots 1
- A 1v1 duel - quickest way to test one angle.
- --host-bots 3
- 2v2.
- --host-bots 5
- 3v3.
- --host-bots 9
- A full 5v5. This is the one that tells you whether a site is defensible.
Bots split evenly across the two teams, each runs in its own muted background window, and they all shut down when you close yours. Add --host-port 27016 to run a second copy alongside the first.
What to look for
- Spawn to site
- Time both teams to both sites. A site that one team reaches ten seconds earlier is not a site, it is a gift.
- Both halves
- Sides swap at halftime, so every angle has to be fair from both ends.
- Sound
- Stand still and listen. Footsteps should tell you where someone is; if a room is silent or every wall sounds identical, check your surface tags.
- Falling
- Try to leave the map. Anywhere you can reach that you should not either needs geometry or a kill_zone.
The whole command line, in one place.
Everything above, condensed.
Client
connect to a server
--host-map host a .blend or .glb and connect
--skip-bake no Cycles bake
--fast-bake 1-sample bake
--no-bake-gpu bake on CPU
--bake-workers worker count
--force-export ignore the bake cache
--capture-ibl force a probe pass, then exit
--capture-ibl-equirect also write equirect previews
--debug-ibl chrome ball in front of the camera
--host-bots bots alongside --host-map
--host-port embedded server port
--name display name
--list-scenarios list built-in test scenarios Server
./server start on 27015
--port listen elsewhere
--map start on a specific map
--team-size players per team
--name name in server browsers Handy scenarios
--test-scenario material_showcase --exit-after 10
--list-scenariosThe built-in scenarios are the same ones the game is tested with - scripted input through the ordinary input path, so what you watch is what a player would get.