A bubble game needs bubbles. A star needs points. In Bubble Penetration, both are created directly in code: the game objects use no imported textures, 3D models, or sprite atlases. Instead, the visible game world emerges from geometry, colors, light, and a little mathematics. The rest is OpenGL — served with a small pinch of chili.
This is more than a visual trick. Because game objects are generated from data, the game-core module can work independently of Android UI and rendering. That boundary has become a central part of the architecture.
The architecture in one glance
Touch / UI
|
v
normalized input
|
v
game-core module: rules, state, time, replay
|
v
GameObject + mesh data
|
v
Android/OpenGL: transforms, lighting, blending, draw calls
The game-core module knows neither Android nor GL10. It decides whether a tap hits a bubble, how much time is awarded, and when a combo ends. The Android side translates that state into a visual representation.
OpenGL starts with geometry, not images
OpenGL does not draw a “bubble”. It draws vertices. Positions, normals, colors, and the current render state combine to produce something visible.
For Bubble Penetration, the smallest common denominator is therefore renderer-neutral data:
data class MeshData(
val vertices: FloatArray,
val normals: FloatArray? = null,
val colors: FloatArray? = null,
) {
val vertexCount: Int get() = vertices.size / 3
}
Each vertex contains three floating-point values for x, y, and z. Normals describe the direction a surface is facing and are essential for lighting. On a sphere, the normalized position vector is also the outward-facing normal.
The geometry is generated in shared Kotlin code. The Android renderer receives data rather than an image file.
The bubble: a sphere made from strips
A sphere can be described as a surface of revolution. We divide the latitude from south pole to north pole and the longitude around the axis into small steps. Each latitude section creates two rings, which are connected with a GL_TRIANGLE_STRIP.
val radius = cos(latitude)
val height = sin(latitude)
val x = radius * cos(longitude)
val z = radius * -sin(longitude)
vertices += listOf(x, height, z)
normals += listOf(x, height, z)
The actual mesh generator uses a configurable angular step. Reduced to its essential loops, it looks like this:
object BubbleMeshFactory {
fun create(smoothnessDegrees: Float = 12f): List<MeshData> {
require(smoothnessDegrees > 0f)
val strips = mutableListOf<MeshData>()
var latitude = -90f
while (latitude < 90f) {
val vertices = mutableListOf<Float>()
val r1 = cos(latitude * PI / 180.0).toFloat()
val r2 = cos((latitude + smoothnessDegrees) * PI / 180.0).toFloat()
val h1 = sin(latitude * PI / 180.0).toFloat()
val h2 = sin((latitude + smoothnessDegrees) * PI / 180.0).toFloat()
var longitude = 0f
while (longitude <= 360f) {
val cosine = cos(longitude * PI / 180.0).toFloat()
val sine = (-sin(longitude * PI / 180.0)).toFloat()
vertices += listOf(
r2 * cosine, h2, r2 * sine,
r1 * cosine, h1, r1 * sine,
)
longitude += smoothnessDegrees
}
strips += MeshData(vertices.toFloatArray(), vertices.toFloatArray())
latitude += smoothnessDegrees
}
return strips
}
}
Smaller steps make the sphere smoother but create more vertices. Larger steps save work and make the bubble more faceted. This control is more convenient than exporting another asset: the shape remains reproducible and directly adjustable in code.
OpenGL draws the strips like this:
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY)
gl.glEnableClientState(GL10.GL_NORMAL_ARRAY)
for (strip in mesh.strips) {
strip.vertices.position(0)
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, strip.vertices)
strip.normals.position(0)
gl.glNormalPointer(GL10.GL_FLOAT, 0, strip.normals)
gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, strip.vertexCount)
}
gl.glDisableClientState(GL10.GL_NORMAL_ARRAY)
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY)
The visual character comes from the render state: a transparent halo, additive blending, a material color, specular lighting, and a second normal rendering pass.
gl.glDisable(GL10.GL_LIGHTING)
gl.glDepthMask(false)
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE)
gl.glColor4f(red, green, blue, haloAlpha)
gl.glPushMatrix()
gl.glScalef(haloScale, haloScale, haloScale)
drawMesh(gl)
gl.glPopMatrix()
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA)
gl.glDepthMask(true)
gl.glEnable(GL10.GL_LIGHTING)
drawMesh(gl)
The halo is not an image around the bubble. It is the same geometry, slightly enlarged and rendered with a different blend mode. That saves assets and keeps the animation scalable.
The star: a 2D design with a 3D edge
The star follows a different idea. Its front face is built from a list of points and a triangle definition:
private val points = arrayOf(
floatArrayOf(0f, 1f),
floatArrayOf(-0.5f, 0f),
floatArrayOf(0.5f, 0f),
floatArrayOf(-1.5f, 0f),
floatArrayOf(-0.75f, -0.75f),
floatArrayOf(-1f, -1.75f),
floatArrayOf(0f, -1.25f),
floatArrayOf(1.5f, 0f),
floatArrayOf(0.75f, -0.75f),
floatArrayOf(1f, -1.75f),
)
private val triangles = intArrayOf(
0, 1, 2, 1, 3, 4, 4, 5, 6,
8, 6, 9, 2, 8, 7, 2, 1, 4,
4, 8, 2, 8, 4, 6,
)
The same 2D points are extruded into several layers: the front face, a glow, a bevel, the side surface, and a color gradient for the front light.
data class Parts(
val face: MeshData,
val glow: MeshData,
val bevel: MeshData,
val side: MeshData,
val faceLight: MeshData,
)
StarMeshFactory provides geometry, normals, and vertex colors for the front light. StarGl decides on materials and blend modes, and thus how these become a glow, a golden face, and an edge. A small point list is enough to create an object that feels spatial even though its basic idea is two-dimensional.
OpenGL is a state machine
OpenGL remembers the blend mode, depth writes, lighting, enabled vertex arrays, and transformation matrix until they are explicitly changed. That makes ordering important:
save matrix
-> transform object
-> draw glow with additive blending
-> reset blend state and depth mask
-> draw lit surface
restore matrix
glPushMatrix() and glPopMatrix() prevent a bubble’s position from moving the next star. Anyone setting a state must also know when to restore it. OpenGL is less a paintbrush than a global drawing machine.
State and logic live in the game-core module
In the game-core module, a GameObject describes position, velocity, scale, color, type, and lifecycle. The module advances time, moves active objects, and processes input:
suspend fun advance(seconds: Float, emit: (MatchEvent) -> Unit) {
if (isGameOver) return
elapsedMs += (seconds * 1_000).toLong()
combo.expireAt(elapsedMs)
if (timerAlarm.shouldTrigger(state.timerValueMs, elapsedMs)) {
emit(MatchEvent.TimerAlarm)
}
state.timerValueMs =
(state.timerValueMs - seconds).coerceAtLeast(0f)
updateCollectColor(state.score, elapsedMs)
updateGameObjects(seconds, elapsedMs)
queuedTargets.toList().forEach { resolveTarget(it, emit) }
queuedTargets.clear()
if (state.timerValueMs <= 0f) {
isGameOver = true
emit(MatchEvent.GameOver)
}
emit(MatchEvent.StateChanged(snapshot()))
}
Notice what is not here: no GLSurfaceView, no matrix, no Android Context, no pixel dimensions. This method can run in a unit test.
Input stays neutral as well. The Android scene normalizes a touch, while the game-core module accepts only reachable, still-valid targets inside their hit area:
fun submitTap(normalizedX: Double, normalizedY: Double): Boolean {
if (isGameOver) return false
val tappedAtMs = elapsedMs
val target = state.activeGameObjects
.filterNot { it.isDisappearFinished || it.isDisappearing }
.filter(::isTapAllowed)
.minByOrNull { hitDistance(it, normalizedX, normalizedY) }
?.takeIf { hitDistance(it, normalizedX, normalizedY) <= 1.0 }
?: return false
queuedTargets += QueuedTarget(target, normalizedX, normalizedY, tappedAtMs)
return true
}
The game-core module then decides whether the bubble had the correct color, whether the star grants bonus time, and whether the combo increases or resets. The renderer reacts to state and events such as TargetCollected or GameOver.
Why this separation is more than “clean code”
The architecture brings concrete benefits:
- Tests stay simple. Timing rules, hit detection, combos, and game over do not need an Android device.
- Replays become possible. Inputs can be recorded with timestamps and object IDs, then played back deterministically.
- Rendering stays replaceable. OpenGL ES is the current presentation, but the game-core module does not depend on it.
- Errors are easier to locate. A wrong score belongs in the game-core module; wrong lighting belongs in the renderer.
- Asset freedom remains practical. Mesh factories and material logic are inspectable building blocks rather than opaque export files.
The central rule is simple: the game-core module provides facts, the renderer provides the effect. The game-core module says, “a golden star is at position X and has this scale and age.” OpenGL derives, among other things, its rotation from that and decides how golden, glowing, and three-dimensional it looks.
What is possible without graphic assets for game objects?
Mathematically generated geometry can provide:
- scalable particle effects and halos,
- gradients using vertex colors,
- lighting through normals and materials,
- parametric animation such as rotation, pop-in, and fade-out,
- different shapes from point lists or angular subdivisions,
- deterministic variants for tests and replays.
Assets are not inherently bad. Complex illustrations, characters, and hand-painted surfaces still benefit from them. For an arcade game with clear shapes, however, code can be the tool, blueprint, and asset pipeline at the same time.
Conclusion: fewer files, more control
Bubble Penetration does not prove that games do not need graphics. It shows something more interesting: graphic assets are only one possible representation of visual information.
A sphere can be an image — or a function. A star can come from a file — or from points, triangles, and a few material states. Once state and logic are separated from UI and rendering, the result is an architecture that can be tested, extended, and connected to unusual rendering approaches.
And when a glowing bubble flies across the screen, nobody notices that not a single PNG is responsible for it. That is what good technology should feel like: visible in the result, pleasantly invisible in the details. 🌶️