Flockfjord is an Android game with no game engine and no 3D asset files. Every mesh, every animation and every colour comes out of Kotlin at runtime, drawn through OpenGL ES 2.0. That sounds like a lot of work. It is less than you would think, and the payoff is that nothing is a black box.
This is the whole approach, using the dog as the worked example.
Three layers, and only three
| Layer | Answers | How often |
|---|---|---|
| Mesh | What shape is this thing? | Once, at startup |
| Placement | Where and facing where, right now? | Every frame |
| Shading | What colour is that in this light? | Every pixel |
The insight that makes the rest easy: a model never knows where it is. The dog mesh always sits at the origin facing +Z. The fact that a dog is standing on a hillside facing north-west is a matrix, multiplied on at draw time and thrown away.
1. A mesh is four arrays
No file format, no importer. A mesh is vertices, normals, colours and indices, uploaded into native buffers because OpenGL is a C API and cannot read memory the JVM garbage collector might move.
The important choice is per-vertex colour instead of textures. There is not a single image file in the game. A mesh can be multi-coloured because every corner carries its own RGBA — which is the foundation of the whole low-poly look, and it means no texture atlas, no UV unwrapping and no memory budget for art.
Everything is boxes
Nearly every model is assembled from axis-aligned boxes:
box(cx, cy, cz, sx, sy, sz, r, g, b, a = 1f)
// centre size colour
One detail does most of the visual work. Each face of a box gets a slightly different shade of the requested colour:
// +Y (top) -> colour x 1.06 (brightest)
// -Y (bottom) -> colour x 0.55 (darkest)
// sides -> colour x 0.92 ... 0.80
That is fake ambient occlusion. It gives a box depth and form before any real lighting is calculated, and without it low-poly models look like flat paper cut-outs.
Boxes cannot make curves, so curves are approximated by chains of shrinking boxes stepping in a direction — that is how the ram’s curled horns and the fox’s tapering muzzle are built. It reads perfectly at phone scale.
2. Placement is three matrices
Model (where in the world), view (where the camera is), projection (perspective). They meet in the one function in the entire project that actually draws triangles:
Matrix.multiplyMM(tmpMV, 0, viewMatrix, 0, model, 0) // MV = view x model
Matrix.multiplyMM(tmpMVP, 0, projectionMatrix, 0, tmpMV, 0) // MVP = proj x MV
GLES20.glUniformMatrix4fv(mvpHandle, 1, false, tmpMVP, 0)
GLES20.glUniformMatrix4fv(mvHandle, 1, false, tmpMV, 0)
// ...bind position/normal/colour buffers, then:
GLES20.glDrawElements(GL_TRIANGLES, mesh.indexCount, GL_UNSIGNED_SHORT, mesh.indexBuffer)
Both matrices go to the shader: MVP produces screen position,
MV is needed for lighting and fog distance.
The order gotcha everybody hits once
Android’s Matrix.translateM and rotateM multiply
on the right. In practice that means the line written last happens
first from the model’s point of view:
Matrix.setIdentityM(model, 0)
Matrix.translateM(model, 0, x, y, z) // 2nd: move into the world
Matrix.rotateM(model, 0, yaw, 0f, 1f, 0f) // 1st: spin on the spot
Swap those two lines and your dog orbits the world origin like a carousel horse. Everyone writes that bug once.
3. The animation system is 18 lines
There is no skeleton and no skinning. Animation is a hierarchy of rigid transforms: a body matrix, and legs that inherit it and add their own swing.
val bob = abs(sin(phase)) * 0.05f * speedNorm
Matrix.setIdentityM(baseModel, 0)
Matrix.translateM(baseModel, 0, pos.x, pos.y + bob, pos.z)
Matrix.rotateM(baseModel, 0, yaw, 0f, 1f, 0f)
drawMesh(body, baseModel) // 1) body
val swingAmp = 38f * speedNorm
for (hip in hips) { // 2) four legs
System.arraycopy(baseModel, 0, legModel, 0, 16) // inherit body
Matrix.translateM(legModel, 0, hip[0], hip[1], hip[2])
val swing = sin(phase + hip[3]) * swingAmp
Matrix.rotateM(legModel, 0, swing, 1f, 0f, 0f)
drawMesh(leg, legModel)
}
The decisive line is the System.arraycopy: the leg inherits
the body’s transform and adds its own. Turn the body and the legs come along
for free. That is the same parent/child idea a real engine’s skeleton uses,
done by hand at one level of depth.
The hip table is where the gait lives
floatArrayOf( 0.09f, 0.27f, 0.20f, 0f), // front right
floatArrayOf(-0.09f, 0.27f, 0.20f, 3.1416f), // front left
floatArrayOf( 0.09f, 0.27f, -0.20f, 3.1416f), // rear right
floatArrayOf(-0.09f, 0.27f, -0.20f, 0f) // rear left
Four numbers per leg: x, y, z and a phase offset. Front-right and rear-left share phase 0; the other diagonal shares phase π. Diagonal pairs moving together is a trot — the dog’s natural working gait. One number per leg, and the walk cycle looks right.
Note the arithmetic: the leg hangs 0.26 down and the hip sits at 0.27, so the paw lands 0.01 above the ground. Get that wrong by two centimetres and your animal either hovers or wades.
Stride comes from distance, not time
walkPhase += speed * dt * 6f
The phase advances with speed, not the clock. So the legs stop when the dog stops, and the stride stretches as it accelerates. Drive the phase from time instead and you get the classic bug of an animal jogging in place while standing still, and skating while running.
One more number does a surprising amount: speedNorm, a 0–1
value, scales both the leg swing (38° max) and the body bob (0.05 max). Walking
looks restrained, galloping looks emphatic, and that is one multiplier rather
than two animations.
4. When the shape itself changes, make another mesh
The dog can lie down on command. Lying is not a pose you can reach by rotating the standing parts — the body flattens, the legs fold underneath and the neck stretches forward. Rigid-body hierarchy can move and rotate parts, but it cannot bend them.
So there is a second mesh, containing the whole animal including its legs, because a lying dog has no walk cycle to animate. The entire state machine is this:
if (dog.lyingDown) {
drawStatic(dogLyingMesh, dog.position, dog.yaw, 1f)
} else {
drawAnimal(dogBodyMesh, dogLegMesh, dogHips,
dog.position, dog.yaw, dog.walkPhase,
min(1f, dog.speed / 4f))
}
Note what is absent: no separate ”standing” branch (standing is the walk cycle at speed zero — the legs simply stop), no transition animation, no animation state machine. The draw code asks the dog what it is doing and draws that. One extra mesh in memory costs nothing next to building a skeletal animation system.
Was it worth skipping the engine?
For this game, yes — and the reasons are specific rather than ideological:
- The APK is tiny. No assets means nothing to download.
- Infinite content for free. Terrain, meshes and sounds are functions, so every stage can be generated instead of authored.
- Nothing is opaque. When something looks wrong, the cause is in a file I can read, not in an engine’s animation blending.
The costs are just as real: no editor, no asset pipeline, no shader graph, and everything an engine gives you for free — physics, UI, particles, audio — is yours to write. For a small game with a strong procedural identity that is a good trade. For a game that needs 200 hand-made models, it would be madness.
