← Nesim Apps

Game Feel in Jetpack Compose Without a Game Engine

By Nesim Razon, independent Android developer · 2026-11-12

#Jetpack Compose#Game design#Android

Cubbles is a block-placing puzzle with cute faces on the blocks, and almost all of its charm is feedback: a piece that settles with a bounce, a row that melts away in a wave, a sound and a buzz that grow with your streak. None of it needs a game engine. This post walks through how it is done in plain Jetpack Compose, what the numbers are, where the shortcuts are, and how two other games of ours solve the same problem differently.

Why a puzzle does not need an engine

An 8 by 8 board, three pieces in a tray and a drag gesture do not need physics, a scene graph or a game loop framework. What they need is a clock and a way to draw. Compose gives you both: a Canvas to paint on, and a frame callback to read time from. Everything below sits on those two things.

One Canvas, one clock

The game screen draws the board, the tray, the effects and the floating text in a single Canvas. A coroutine started with a launched effect loops forever: it waits for the next frame with withFrameNanos, ignores the frame time it is given, writes the current uptime in milliseconds into a state variable called now, and drops any finished effects from a list.

The important detail is where now is read. It is read inside the Canvas draw lambda, so each frame invalidates the drawing only, not the composition of the whole screen. The score text and the buttons around the board do not recompose sixty times a second. The board itself is a plain mutable object rather than observable state, so a small counter is bumped after each move to make the Canvas redraw. The cost of this design is honest: the loop never stops, even when no effect is running, because blinking eyes and the gently bobbing tray pieces also need a clock.

Effects are data, not animations

Instead of one Animatable per block, the game keeps a list of small effect objects. There is a sealed class with a start time and a duration, and subclasses for a placement pop, a line clear, a floating text, a screen shake, a particle burst, a color flash and a sweeping light. They are created at the instant a piece is dropped and appended to the list.

The drawing code turns each one into a progress value between zero and one, by subtracting the start time from now and dividing by the duration, and derives everything from that number. There is no coroutine per effect, nothing to cancel, and nothing that can drift out of sync with the board. Layering is free: a big clear simply adds several effects to the list.

Squash, stretch and the wave

A placed piece pops for 480 milliseconds. Its scale is one plus 0.28 times a sine over the first part of the animation, multiplied by one minus the progress, so it swells and settles with a fading amplitude. To be accurate, this is a damped swell rather than a volume-preserving squash and stretch: the blocks are rounded squares scaled uniformly, which is cheap and reads clearly at phone size.

The clear animation is where the personality is. Each cleared cell waits a delay of 28 milliseconds per step of Manhattan distance from the center of the piece you placed, so the clear spreads outward like a ripple and tells your eye where it began. Then the cell swells to 1.4 times its size during the first 30 percent of 380 milliseconds, shrinks to nothing, mixes toward white and fades its alpha, and wears a happy face until 70 percent of the way through. One smoothstep function, the classic three t squared minus two t cubed, is the only easing curve used anywhere in the file.

Faces are just a few arcs

Every block has a face drawn from circles, lines and arcs, with a mood chosen per frame: normal, happy, excited or sad. Blinking needs no state. The eyes close when the current time plus the cell index times 911, taken modulo 3700 milliseconds, is under 130, so each block blinks on its own schedule. The eyes also shift by four percent of the block size toward the finger while you drag, which is a small thing that makes the board feel alive.

Particles without a particle system

A particle here is an immutable object with a start position, a velocity, a size, a color and a spin. Nothing is updated per frame. The draw code computes the position in closed form from the elapsed time: start plus velocity times t, with a gravity term of half g times t squared added to the vertical axis, where g is sixteen cell heights per second squared. There is no pool and no per-particle state to maintain.

Counts are bounded by design. Each cleared cell emits 2 plus the hype level, capped at 4, particles, where hype is lines cleared plus the combo minus one. A board has 64 cells, so a single burst cannot exceed 384 particles. A perfect clear adds 90 pieces of confetti and a new record adds 70. A burst lives 1.1 seconds, shrinks to 40 percent of its size and fades out. Squares spin and circles do not. All randomness comes from a generator seeded with the event time, so effects are reproducible while debugging.

Floating text that stays readable

Floating text such as the score, the combo and the praise words rises with the same smoothstep, pops in with a quick overshoot and stays for 1.9 seconds. It used to vanish in one second and players missed it. The words are drawn with a dark outline made of eight shifted copies, because white text on a light bathroom-tile background is unreadable without one. A real bug lives here too: on very small screens a long praise word was wider than the space available, and a clamp call with an inverted range crashed the app. The fix was a small helper that never inverts the range, plus shrinking the text to fit.

Sound without sound files

There are no audio assets. At first use, a sound is synthesized into a short array of 16-bit samples at 22,050 Hz mono: a sine at the note frequency plus a second harmonic at 20 percent, multiplied by an exponential decay, with a 60-sample attack ramp, about three milliseconds, to avoid the click you get from starting at full amplitude. The arrays are cached by name, and each playback builds a static-mode AudioTrack with that array, tagged as game usage, and releases it from a position marker when it finishes.

Haptics that you can actually feel

The vibration class drives the vibrator directly. A comment in the code explains why: the standard view haptic call follows the user's touch feedback setting and was barely perceptible on many phones in the first release. Instead the game builds waveforms, with amplitude control where the hardware reports it and plain timings where it does not, and wraps the call so a missing vibrator never crashes anything.

The rule that keeps it honest

Feedback is cheap to add and easy to overdo, so the project notes set a rule: no effect for its own sake. The puzzle must stay readable and a bad moment must never feel like a punishment. In code that looks like this. A ghost of the piece and a highlight on the rows it would clear appear before you drop. The dragged piece is lifted 1.6 cells above your finger so that your hand does not hide it. Trays are generated so that a legal move always exists. And the game over screen only says how close you were to your best score when you are within 60 percent of it; otherwise it simply shows the best, so a poor run is not followed by a scolding.

One gap, stated plainly: Cubbles has no reduced-motion switch yet. Our bingo game does, and it shows the pattern. A setting is exposed through a composition local, and the confetti and the looping home screen animations check it and stand down.

The same ideas in two other games

Where the screen is made of widgets rather than one Canvas, the standard animation API fits better. In the bingo game, a drawn ball pops in with an Animatable and a bouncy spring, scaling from 0.2 to 1 while it rotates. The daub on a bingo card uses an animate-as-state call with a spring of damping 0.42 so it lands with a wobble. A rejected tap increments an integer key, and a launched effect on that key snaps a shake value to one and springs it back to zero, read inside a graphics layer so nothing recomposes.

In a space puzzle of ours, the frame loop moves each gate a fixed 38 percent of the remaining distance toward its target cell on every frame. It is the simplest possible smoothing and it feels good, with the caveat that the speed depends on the frame rate. A rule of thumb from all three: use the animation API for widgets, and time-derived drawing for free-form scenes.

What to take away

Keep effects as small data objects with a start time and a duration, derive everything from progress, read your clock inside the draw lambda, cap every effect, and let the game rules run ahead of the animation. You get a surprising amount of life from a few hundred lines of Canvas code and a sine function. If you want to see these numbers at work, Cubbles is the game they were tuned for.

Frequently asked questions

Can you build game feel in Jetpack Compose without a game engine?

Yes, for puzzle and casual games. One Canvas, a frame loop with withFrameNanos and time-derived drawing cover squash, particles and screen shake.

How do you avoid recomposing the whole screen every frame?

Read the clock state only inside the Canvas draw lambda. Then each frame invalidates drawing, not composition.

How are the sounds made without audio files?

They are synthesized once into short arrays of samples, cached, and played with a static-mode AudioTrack that is released when playback ends.

Why drive the vibrator directly?

The standard haptic call depends on the user's touch feedback setting and was hardly felt on many phones, so the game builds its own waveforms.

Does more feedback always make a game better?

No. Effects should never hide the puzzle or punish the player, so each one here has a cap and the game over screen avoids discouraging messages.

More from the blog