- Python 96.2%
- Shell 3.8%
| assets | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| blender.pin | ||
| build.sx | ||
| main.sx | ||
| README.md | ||
| server.sx | ||
| sx.pin | ||
hulken
A 3D first-person shooter for iPad and desktop (macOS, Windows, Linux), written in sx.
Building
hulken builds with the sx commit sx.pin names, always through tools/sx, never an sx on PATH. tools/sx runs that commit's library too, whatever SX_STDLIB_PATH the shell exports. Its first use on a host fetches that commit and builds it into ~/.cache/sx, which needs git, zig 0.16 and LLVM 22 (brew install llvm@22). The app also needs SDL3 (brew install sdl3), and the bake needs Blender at the version blender.pin names (see Installing Blender).
tools/sx build main.sx
open sx-out/macos/Hulken.app
Every build bakes the scenes first, and a failed bake fails the build.
To take an sx fix, set sx.pin to the sx commit that carries it. SX_REMOTE=~/projects/sx fetches the commit from a local checkout instead of git.swipelab.com.
Installing Blender
The bake accepts only the Blender blender.pin names, 5.2.0, and runs the blender on PATH. Install the macOS arm64 build from https://download.blender.org/release/Blender5.2/blender-5.2.0-macos-arm64.dmg into /Applications, and put a blender on PATH that runs it, such as a script holding
#!/bin/sh
exec "/Applications/Blender.app/Contents/MacOS/Blender" "$@"
which is what Homebrew's cask writes. brew install --cask blender installs 5.2.3, which the bake refuses, and brew upgrade moves an installed 5.2.0 cask past the pin.
Scenes
The scenes are Blender files under assets/scenes/, their prefabs Blender files under assets/prefabs/, and their textures PNG files under assets/textures/. The app opens the first map, first_map.blend, at its first spawn marker; the test scene, test_scene.blend, is the tests' fixture. tools/bake.sh bakes each scene into baked/, which the app and the tests read; tools/sx build main.sx and tools/run_tests.sh run it, and it skips while baked/bake.stamp matches every file that makes baked/. For each scene, baked/ holds:
<scene>.hbs, the render file: the textures, meshes, prefabs and lighting the app draws, and each rigid body's meshes in its own space.<scene>.hbw, the world file: every triangle the render file draws opaque, but those of a material whose custom propertyhulken_collideis false, as the collision world the server moves players against, with its hierarchy, how far the free space about each edge of each triangle turns, seen from each face, to the next triangle sharing the edge, from which bodies' contacts read the edge as smooth, sharp or concave, and the map's bounds, the map's spawn markers, its dummy markers, its water volumes, and its rigid bodies, each with its shape, mass, material, damping and start, and the gravity they fall under. Nobody walks out of the bounds.
Beside the scenes, baked/dummy.hbm is the training dummies' model: the skeleton assets/prefabs/skeleton_minion.blend holds, its rig and the meshes the rig skins, as Blender renders it in its saved pose, each triangle in the own space of the dummy's part that carries it. Each bone that carries a part names it in its custom property hulken_part, one of the parts of the dummy's template in src/sim/dummy.sx, and a triangle goes to the part whose bones weigh its corners most. The bake fails on a model of other than one rig and the meshes it alone skins, on a mesh of it Blender does not render or renders as an instance, on any setting of the scene, its view layer or a mesh's object that changes Blender's render and differs from a new one's, on a view transform other than Standard or a working space other than Linear Rec.709, on a vertex no bone with a part weighs, and on the materials a scene's bake fails on. It also fails unless the template fits the skeleton: every bone a part carries lies within that part's capsule, every bone starting from a bone of another part starts at the template's joint between the two, every joint of the template sits at such a bone's start, every part carries a triangle, and the skeleton's highest point is the top of the head a hung dummy hangs from. So a dummy bends where its skeleton bends, and each part draws within 0.25 m of the capsule it collides as.
A prefab is anything Blender draws as instances: a collection instanced by empties, local or linked from a .blend under assets/prefabs/, or a Geometry Nodes or particle instance. The bake keeps each instanced geometry's triangles once, in its own space, with one transform per instance, and hulken draws each of its materials with one instanced draw; every instance collides, but for its hulken_collide false materials. Each .blend under assets/prefabs/ holds one model in a collection named for it, with its collision proxy when it has one, which a scene links by relative path and instances.
A scene marks what Blender has no notion of with custom properties, which change nothing Blender draws:
- Spawn marker: an Empty whose custom property
hulken_spawnis true. Its origin is where a player's feet stand, and its local +Y axis, levelled, is the way the player faces. A joining player stands at the map's first, and each player that respawns at the next in turn. The bake fails on a marked object that is not an empty, and on a scene with no spawn marker. - Dummy marker: an Empty whose custom property
hulken_dummyis true. Its origin is the point a training dummy hangs from by the top of its head, and its local +Y axis, levelled, is the way the dummy faces. The server hangs a dummy at each, its 12 parts after the map's rigid bodies. The bake fails on a marked object that is not an empty. - Passable material: a material whose custom property
hulken_collideis false draws without colliding, as a tree's leaves and bark do. The bake fails on ahulken_collidethat is not true or false. - Collision proxy: a mesh whose custom property
hulken_collisionis true collides and draws nothing, standing in for what its prefab draws, as a tree's few-triangle trunk does. Blender renders nothing of it: every ray visibility of its object (Camera, Diffuse, Glossy, Transmission, Volume Scatter and Shadow) is off. The bake fails on ahulken_collisionthat is not true or false, on one on an object that is not a mesh or is a water volume, and on a proxy with any ray visibility on. - Water volume: a mesh whose custom property
hulken_wateris true: the axis-aligned box its vertices span, its triangles lying on the box's six faces and covering each, whose top is the water's surface. Its custom propertieshulken_underwater_colorandhulken_underwater_fog_density, on the object, are the colour and fog density per metre seen with the eye inside it, hulken's own values. Its material is a Principled BSDF at Render Method Blended, whose Base Color and Alpha hulken blends over what lies behind it; water does not collide. The bake fails on a water mesh that is not such a box, on a water material that is not Blended, on a material that water and an object that is not water both draw, and on a water object missing either underwater property.
A rigid body is Blender's own: a mesh object given a rigid body in its Physics properties, Rigid Body panel, which puts it in the scene's rigid body world's collection. The server steps each Active body from where the scene starts it, under the scene's Gravity, and the app draws it where the server puts it. A rigid body is neither a water volume nor a collision proxy: the bake fails on one marked as either.
- Shapes. A body's Shape is Box, Sphere, Capsule, Cylinder or Convex Hull, sized as Blender sizes it. A Box is centred on the object's origin, half its dimensions each way. A Sphere is centred on the origin, its radius half the largest dimension. A Capsule lies along the object's local Z through the origin, its radius half the larger of the X and Y dimensions, its whole length the Z dimension, or twice its radius when the Z dimension is shorter. A Cylinder lies along the object's local Z, centred on the origin, its radius half the larger of the X and Y dimensions and its length the Z dimension; it collides as an exact cylinder, its side round and its caps flat and sharp-rimmed, so on its side it rolls as a ball does. A Convex Hull is the hull of the mesh's vertices where they lie, each face of the hull flat, so a hull of a round mesh tips from face to face rather than rolling. So a Box, a Sphere or a Cylinder draws where it collides only with its object's origin at its mesh's bounds' centre, where
kenney.py --centreputs a prop's. - What the bake reads and refuses. It reads a body's shape, its Mass, Friction, Restitution, and Linear and Angular Damping, where it starts, and its mesh with its scale applied, a mesh linked from a prefab included. It fails on a body of any other shape, an Animated one or one not Dynamic, one parented, drawn as an instance or carrying a modifier, one its Scale, its Delta Scale and its constraints together mirror in the world, a Sphere or a Capsule they scale unevenly, whose radius Blender scales by the X scale alone for a Sphere and by the Y scale for a Capsule, a Cylinder they scale unevenly along X and Y, whose radius Blender scales by the X scale alone, one outside the rigid body world's collection, one drawing a water material or one whose
hulken_collideis false, and any other rigid body setting that differs from a new body's. It fails on any rigid body world setting that differs from a new world's but its collection and Blender's preview settings (Substeps, Iterations, Split Impulse and its cache's frames), on a baked cache, which Blender draws in place of where the bodies start, and on any rigid body constraint. - Passive bodies. A Passive body of shape Mesh is map, colliding by its triangles as a static mesh does, its rigid body at a new body's values: the map is one surface, of friction 0.5 and restitution 0. It is there so a drop played in Blender's viewport lands on it, as the first map's terrain is. Blender's Add Passive makes a Convex Hull, which the bake refuses until its shape is set to Mesh, and the bake fails on a Passive body of any other shape.
- Materials. Two touching bodies take the square root of their frictions' product and the greater of their restitutions, and a body on the map takes them against the map's 0.5 and 0. Every body has a rolling resistance of 0.15, a hard ball's or wheel's on grass, as Blender's rigid bodies carry none: a contact resists a body turning on what it rests on, rolling or spinning in place, by the greater of the two's rolling resistances times the greater of the radii they roll on there times the force pressing them together. A Sphere and a Capsule roll on their radius, and so does a Cylinder where its side touches, its axis square to the contact. On its rim a Cylinder rolls on its radius about its own axis alone, so it tips about the rim freely; lying flat on its cap it rolls on none, as a Box and a Convex Hull do. Two bodies touching by their rims roll on none unless their axes are parallel. So a rolling Sphere, Capsule or Cylinder, on its side or on its rims, slows to a stop on ground flatter than 8.5° and rolls down steeper, and a Cylinder on its rim falls onto its end or its side, whatever it rests on.
- Blender's simulation is a preview of hulken's, not a reference. Its Bullet multiplies frictions and restitutions, so in Blender's preview a body slides sooner and does not bounce on the terrain, and it has no rolling resistance, so a ball rolls on there. It keeps a 0.04 m margin, and only rigid bodies are there for it, so the ruins, trees and wreck are not.
To change a scene, open its .blend in Blender, edit it, save, and run tools/sx build main.sx: the app draws the change. Keep textures as PNG files under assets/textures/, linked by a relative path, never packed into the .blend. A material whose Principled BSDF's Alpha is fed by the Alpha of the Image Texture feeding its Base Color is a cut-out, as leaves are: hulken draws it where the texture's alpha is at least one half, as Blender's Dithered render method draws an alpha of 0 and 1, both faces lit, needing no sorting; its image's Alpha Mode is Straight. Only a cut-out may draw an image with a texel that is not opaque. Each .blend and texture has a .md beside it naming its source and licence, and the script that made it stays beside it: first_map.py made the first map, first_map_water.py added its lake and ocean, first_map_bodies.py its props, first_map_dummies.py its dummies' frame and markers, first_map_prefabs.py puts in its forest and shipwreck, polyhaven.py wrote the Poly Haven textures it draws from their downloaded JPGs, assets/prefabs/kaykit.py imported the dummies' skeleton from a KayKit character pack's glTF model, keeping its rig, skinning weights, UVs, Base Color and base-colour image, scaling it to a dummy's 1.81 m and posing its arms and feet to hang, assets/prefabs/polyhaven_tree.py made each tree prefab from a Poly Haven tree's .blend, reduced to the triangles the forest can draw, its trunk cut above the scanned ground and carried on down into the ground, with its trunk's collision proxy, and assets/prefabs/kenney.py imported the shipwreck and the props from a Kenney pack's glTF models, keeping each one's geometry, UVs, Base Color and base-colour image and setting every other material value to a new material's.
- The bake judges a scene as EEVEE renders it. It fails on any setting it does not draw as Blender does, naming the datablock and the setting.
- Blender colours are scene-linear, and the bake sRGB-encodes them, so a flat colour and a texture that Blender draws alike draw alike in hulken. A sun's strength is irradiance, which the bake divides by π: a white surface facing a sun of strength π draws the sun's colour.
- The scene stays on the Standard view transform, so the colours Blender displays directly look as hulken draws them. Blender's render of the lit scene still differs: hulken lights it with its own ambient, from the World's custom properties
hulken_sky_ambient,hulken_ground_ambientandhulken_fog_density, and draws no shadows.
Playing
Launching the app hosts a game on UDP port 27960 and plays in it; --port <port> hosts on another port. --join <address>[:port] joins the game hosted at that numeric IPv4 address instead, on the port after the colon, or else on 27960 or the one --port names. Beside a hosting app on the same Mac:
open -n sx-out/macos/Hulken.app --args --join 127.0.0.1
and on another Mac on the LAN, --join the host's address, such as --join 192.168.1.20. Each player appears at the map's first spawn marker and sees every other player as a skeleton like the training dummies', walking, turning and jumping where the server has it; players pass through each other. A game holds 8 players. The host's server ticks on its own thread, so a stalled frame of the hosting app stalls no other player, and its game ends when it quits. A joining app whose protocol version or world file differs from the host's, or that would be a ninth player, is refused and closes, printing why, and so does one that has heard nothing from its server for 10 s.
The server sends each app a snapshot every tick, written against the newest snapshot the app has told it it holds, of those it sent in the last second: a player or body unchanged since that one is not written, and a changed one carries the values that changed, exact to the bit. An app that holds none of them gets a whole one. With 8 players walking and the bodies asleep, the host sends each app about 15 KB a second while its round trip is under a tick, as on a LAN, where whole snapshots would take about 150 KB. Each snapshot also carries the players of the two ticks before it, and those of a tick its base does not hold are written against the base's own, so an app one to two ticks away gets about 25 KB a second, and one further about 35 KB.
A player's own view answers its keys at once, however far the server is: the app runs its own player's movement for each tick's command as it sends it, against the map and its own copy of the bodies, which it steps after each command with its player pushing them as the server's does. It corrects both from the server's snapshots, a few ticks behind the newest, by running its commands again from the body and the bodies the server sent, each body moving as it was. Over open ground, and pushing bodies no other player touches, the server's run is the app's to the bit, so nothing is ever corrected; a body another player pushes too is corrected as the snapshots show it move. The bodies a player pushes draw where its app's run puts them, against its own body, until they come to rest. The server runs each command once, in order, one a tick, and never guesses one that has not come; each command goes out with every one the server has not acknowledged, so a lost datagram loses none, and the app nudges its tick rate to keep about two waiting at the server. Every other player and body draws about half a round trip and a tenth of a second in the past, a few ticks behind the newest snapshot, between the snapshots either side, so that a late or lost snapshot does not stop it.
A shot is aimed at the other players where its app drew them. It goes with the next tick's command, which carries its view time: the render time of the frame drawn before that command was made. The server keeps each living player's capsule after every tick for the last second, and rewinds every other living player to that time, between the ticks either side of it as the app drew them, at most 0.5 s back. The shot hits the nearest of those capsules, the map and the bodies where they lie, within 600 m, and each snapshot names what each of the app's shots hit, a player, a body, the map or nothing, until the app acknowledges a snapshot that named it.
--lag <ms>, --loss <percent> and --jitter <ms> make the app's own link to its game worse on purpose, from a seeded generator: each datagram it sends or receives waits half the lag, give or take the jitter, or is lost at the loss rate. Two players launched with --lag 150 --loss 2 --jitter 10 play as over a poor connection.
Click the window to lock the pointer and look around with the mouse; Escape releases it. With the pointer locked, a left click fires a shot from the eye along the look, at the crosshair in the middle of the view. W, A, S and D walk, Shift sprints, Space jumps and Left Control crouches.
Crates, barrels and cannon balls stand in a clearing in front of where the player first appears, and settle as the map loads. The player stands on them and climbs onto them as onto any block, and pushes what it walks into with at most 500 N: a crate slides ahead of it, a cannon ball or a barrel on its side rolls away and comes to rest, a stack topples, and a body too heavy to slide stops it. A body moving into the player shoves it aside, and one that comes down onto it moves it aside along the ground. A body moving under the player does not carry it.
Two training dummies, skeletons, hang from a frame beside the props. Walking into one swings it, its limbs flopping within a person's reach, and it settles.
In water deep enough to swim in, the player swims the way it looks, Shift sprinting; Space rises and Left Control sinks, and with neither it slowly sinks. Holding Space at the surface keeps the eye just above the water, and holding Space while swimming at a bank up to 1 m above the water climbs out onto it.
A player has 100 health, shown by the bar at the bottom left. A shot that hits it costs 20 health, so 5 kill. A fall costs 5 health for each metre past its first 3, and sliding down ground steeper than 45° counts as falling. It holds 30 s of air, shown by a bar above the health bar while it is short: the air drains while the eye is under water and refills at the surface, and with none left the player drowns, losing 10 health at once and each second after. At 0 the player dies and the view turns red; 3 s later it comes back at the map's next spawn marker, facing the way the marker faces.
Bots
tools/bots.sx joins scripted players to a game, as apps join it:
tools/sx build tools/bots.sx -o sx-out/tools/bots
sx-out/tools/bots --join 127.0.0.1 --count 7
Run it from the repository root, where it reads the world file. --join <address>[:port] names the game, on port 27960 unless the address names one, and --count <n> how many players to join, 1 unless given. Each player appears at the map's first spawn marker and walks a loop of its own through the clearing in front of it, from where one of the map's rigid bodies starts or a dummy hangs to the next, pushing what it walks into; ordered by their bearing from the clearing's centre, those places are dealt in turn to 8 loops, one for each player a game holds. A player that cannot reach its next place within 6 s heads for the one after. The tool prints each player that joins and why one is refused, and on Ctrl-C each leaves. With an app hosting on the same Mac, seven skeletons walk about the clearing pushing the crates.
Dedicated server
server.sx is the dedicated server: the game a hosting app's server runs, over baked/first_map.hbw alone, with no window, GPU or SDL. It builds into one binary, baking the scenes first as the app's build does:
tools/sx build server.sx -o sx-out/macos/hulken-server
Run it from the repository root, where it reads the world file:
sx-out/macos/hulken-server
It serves on UDP port 27960, or the one --port <port> names, prints each player that joins and leaves, and ticks at 60 Hz until Ctrl-C or SIGTERM stops it. Apps join it as they join a hosting app: on the same Mac, start each player's app with
open -n sx-out/macos/Hulken.app --args --join 127.0.0.1
and on another Mac on the LAN, --join the server's address. While the server holds port 27960, an app launched without --join cannot host on it, and closes, printing why.
Tests
bash tools/run_tests.sh
It bakes, builds the dedicated server and the bots tool, which tests/dedicated_server.sx and tests/bots.sx start, runs every tests/*.sx, then tests/bake_extract.py and tests/first_map_terrain.py in Blender.
bash tools/gate.sh fast|full runs what the sak gates run: it builds the app, runs the physics bench and the server bench, then runs the tests. Regenerate the render goldens under tests/pixels/ with SX_UPDATE_PIXELS=1.
The physics bench, tools/bench/physics.sx, holds the physics to 2 ms a tick. It steps 8 ragdolls and 4 vehicles, 116 bodies and 104 joints, over the first map's collision world, on the level clearing in front of its first spawn marker (tools/bench/physics_scene.sx): free training dummies, and cars of a 1500 kg convex hull chassis on four 25 kg sphere wheels hinged at its corners. The cars drop 0.5 m onto their wheels; a row of dummies topples against the cars' ends, and a second row behind it topples onto the first. It steps the scene 600 ticks 5 times from the same start and times each tick by the stepping thread's CPU time, so time spent preempted does not count; stepping is deterministic, so a tick's cost is its least time over the 5 runs. It prints the worst and the median tick, and for the worst the bodies awake, the contact points, the sweep's work and the time spent finding contacts against solving, and fails when the worst tick passes 2 ms. The gate builds it into sx-out/tools/physics_bench and runs it under taskpolicy -b, which keeps it on the slower of the host's core tiers, where a busy host may run the server's thread:
tools/sx build tools/bench/physics.sx -o sx-out/tools/physics_bench
taskpolicy -b sx-out/tools/physics_bench
tests/physics_bench_scene.sx steps the same scene under tools/sx run and checks that every car meets a dummy and every dummy another dummy, that no body sinks into the terrain or leaves the map, that the dummies keep their joints' limits, that the wheels stay on their hinges, and that stepping it twice gives the same states.
The server bench, tools/bench/server.sx, holds the first map's server to 2 ms a tick with 8 players. Its scene (tools/bench/server_scene.sx) joins 8 scripted players to the server over a loopback, each walking its loop from the first spawn marker through the clearing as a bot does, into the props and the dummies, which wake; before each of the server's ticks each player reads its snapshots and sends the tick's command. It steps the server 1200 ticks 5 times from the same start, timing each tick, its taking the commands, stepping the game and sending the snapshots, by the stepping thread's CPU time, so time spent preempted does not count, nor the players' own; every run does the same work, so a tick's cost is its least time over the 5 runs. It prints the worst and the median tick, and for the worst the bodies awake, the contact points and the bytes sent, and fails when the worst tick passes 2 ms. The gate builds it into sx-out/tools/server_bench and runs it under taskpolicy -b beside the physics bench:
tools/sx build tools/bench/server.sx -o sx-out/tools/server_bench
taskpolicy -b sx-out/tools/server_bench
tests/server_bench_scene.sx steps the same scene and checks that every player comes into the clearing, that every body of the map, the dummies' parts among them, then moves, that every snapshot decodes to the one sent, and that stepping it twice gives the same states.
tools/forest_bench.sx times the first map's forest at 1280x800 on Metal from a grid of eyes standing in it and prints the slowest view's frame; build it with tools/sx build tools/forest_bench.sx -o sx-out/forest_bench and run sx-out/forest_bench from the repository root.