I wanted to build something small and fun in Godot 4. Something I could play on my phone. A dino collecting chickens on a grassy island, competing against an NPC dino doing the same thing.

The whole thing came together in one evening — five commits, from a walking dino sprite to a fully playable Android game. Along the way I hit some interesting problems: elliptical boundary math, feather particle effects without a particle system, and a score HUD that refused to show on Android.
Here’s how it went.
Commit 1: Walking Dino, Wandering Chickens
The first commit was the foundation: a player dino with idle/walk/jump animations, 15 chickens wandering randomly, and a terrain background replacing the debug grid.
Player controls were straightforward — CharacterBody2D with keyboard input and touch drag. The dino moves at 250px/s, and touching the screen sets a drag direction:
if is_touching and finger_count == 1:
var drag := last_touch_pos - touch_origin
if drag.length() > 20.0:
move_dir = drag.normalized()
The 20px dead zone prevents accidental micro-movements.
Chickens were Area2D nodes with a 4-frame walk animation built entirely in code:
var frames := SpriteFrames.new()
frames.add_animation(&"walk")
frames.set_animation_loop(&"walk", true)
frames.set_animation_speed(&"walk", 6.0)
for name in walk_names:
var tex := load(name) as Texture2D
frames.add_frame(&"walk", tex)
sprite.sprite_frames = frames
No .tres files, no editor setup. Every animation in this project is built programmatically. It’s more lines, but it means I can change a frame count or path without reopening the editor.
Chickens pick a random direction every 1.5–4 seconds, with a 30% chance of standing still. When a chicken touches the player, it triggers a jump animation and calls queue_free().
The pinch-to-zoom camera was important for mobile. Two-finger pinch adjusts the Camera2D zoom between 0.25x and 4x. This was one of the first things I got working because testing on a phone without zoom feels terrible.
Commit 2: NPC Dino, Elliptical Boundaries, Feathers
This was the big feature commit. The game transformed from “collect chickens alone” to “race an NPC dino for chickens.”

The NPC Dino
The red dino has two modes: wander (speed 50) and chase (speed 120). It scans for the nearest chicken within 500px:
func _find_nearest_chicken() -> Node2D:
var nearest: Node2D = null
var nearest_dist := CHASE_RANGE
var chickens := get_tree().get_nodes_in_group("chickens")
for chicken in chickens:
if not is_instance_valid(chicken):
continue
var dist := global_position.distance_to(chicken.global_position)
if dist < nearest_dist:
nearest_dist = dist
nearest = chicken
return nearest
When a chicken is in range, the NPC locks on and chases at 2.4x wander speed. Without a target, it picks random directions. This creates a natural behavior: the NPC ambles around until it spots a chicken, then beelines for it.
Collision between dinos uses get_slide_collision() to detect who you bumped into. Both dinos get knocked back with a squish animation:
var squish_x := 1.3 if abs(from_direction.x) > abs(from_direction.y) else 0.7
var squish_y := 0.7 if abs(from_direction.x) > abs(from_direction.y) else 1.3
var tween := create_tween()
tween.tween_property(sprite, "scale", Vector2(squish_x, squish_y), 0.08)
tween.tween_property(sprite, "scale", original_scale, 0.12).set_ease(Tween.EASE_OUT)
Two bounces for a satisfying impact feel. The squish direction depends on which axis the bump came from — horizontal bumps compress width and stretch height, vertical bumps do the opposite.
The Elliptical Map
This was the most satisfying problem. The original code used rectangular boundaries:
// Before: rectangular clamping
if position.x < MARGIN:
position.x = MARGIN
move_dir.x = abs(move_dir.x)
But the terrain background is an island — an ellipse. Rectangular clamping looked wrong. Dinos would slide along invisible walls at the corners. The island is 1440×960 but the play area should feel like an organic, rounded space.
The fix: clamp everything to an ellipse. The terrain image is 1440×960, so the semi-axes are 720 and 480. Normalizing a position against these gives a unitless distance from center:
func _clamp_to_ellipse():
var dx := (position.x - MAP_CX) / ELLIPSE_A
var dy := (position.y - MAP_CY) / ELLIPSE_B
var dist_sq := dx * dx + dy * dy
var margin := RADIUS / min(ELLIPSE_A, ELLIPSE_B)
var limit := 1.0 - margin
if dist_sq > limit * limit:
var dist := sqrt(dist_sq)
var scale_factor := limit / dist
position.x = MAP_CX + (position.x - MAP_CX) * scale_factor
position.y = MAP_CY + (position.y - MAP_CY) * scale_factor
dx and dy are normalized to the ellipse’s semi-axes, so dist_sq > 1.0 means “outside the ellipse.” The margin subtracts the dino’s radius so it doesn’t visually clip the edge. scale_factor projects the position back onto the ellipse boundary.
This same function is called for the player, the NPC, and the chickens. It’s the shape of the world.
Spawning chickens in the ellipse uses the same math with rejection sampling via sqrt(randf()) for uniform distribution inside an ellipse:
func _random_point_in_ellipse() -> Vector2:
var angle := randf() * TAU
var r := sqrt(randf()) * SPAWN_MARGIN
return Vector2(
MAP_CX + cos(angle) * ELLIPSE_A * r,
MAP_CY + sin(angle) * ELLIPSE_B * r
)
The sqrt(randf()) is important — without it, chickens would cluster near the center.
Feather Explosions
When a chicken is collected, it spawns feather sprites that fly outward and fade. All done with Sprite2D + Tween, no GPUParticles2D:
func _spawn_feathers():
var feather_colors := [
Color(1, 1, 0.95), # white
Color(1, 0.85, 0.3), # gold
Color(220, 40, 40), # red
Color(255, 165, 0), # orange
Color(1, 1, 1), # bright white
]
for i in range(8):
var feather := Sprite2D.new()
feather.texture = load("res://assets/feather_%d.png" % ((i % 4) + 1))
feather.modulate = feather_colors[i % feather_colors.size()]
# ... position, tween outward + rotate + fade
Eight feathers per chicken, cycling through four feather textures and five colors. Each one flies in a random direction, rotates randomly, and fades to transparent over 0.5 seconds. It’s cheap, it’s colorful, and it feels right.

The Terrain
The terrain image (2.3MB PNG) was generated as a 1440×960 grass field with rocks, sand patches, and pebbles. A vignette darkens the edges to blend into the black background — the “void” outside the island.
Setting the clear color to black in project.godot:
environment/defaults/default_clear_color=Color(0, 0, 0, 1)
This means anything outside the island is just black. The elliptical clamping ensures nothing ever floats in the void.
Commit 3: Score Display (WIP — Not Visible on Android)
The first attempt at a score HUD used a CanvasLayer with TextureRect icons and Label nodes for the scores. Player score in cyan, NPC score in red, with dino and chicken icons next to each number.
It worked in the Godot editor. It did not work on Android.
Commit 4: Fix Score Display Using _draw()
The CanvasLayer + Label approach simply didn’t render on Android. Labels disappeared, TextureRects were invisible. The hierarchy was there, the nodes were in the tree, but nothing drew.
The fix: throw away all the scene nodes and draw everything manually with _draw() on a Node2D:
func _draw():
if not font:
return
var vp_size := get_viewport().get_visible_rect().size
var x := vp_size.x - 150.0
var y := 20.0
var font_size := 28
if icon_player:
draw_texture_rect(icon_player, Rect2(x, y, 40, 40), false)
if icon_chicken:
draw_texture_rect(icon_chicken, Rect2(x + 46, y + 6, 26, 26), false)
# Shadow, then bright text on top
draw_string(font, Vector2(x + 80, y + 30), str(player_score), ...)
draw_string(font, Vector2(x + 78, y + 28), str(player_score), ...)
Two draw_string calls per score: a dark shadow offset by (2, 2) and the bright text on top. This gives a readable drop shadow without any filter or shader.
The vp_size approach keeps everything pinned to the top-right regardless of camera position. queue_redraw() is called whenever a score changes.
This commit got the scores visible on Android, but the layout was still off.
Commit 5: CanvasLayer + _draw() — The Final Form
The final commit moved the DrawNode into a CanvasLayer so it renders on a screen-space overlay, independent of the camera. The scene tree became:
CanvasLayer (ScoreDisplay, layer=10)
└── Node2D (DrawNode) — runs _draw() with icons and scores
The CanvasLayer ensures the HUD isn’t affected by the pinch-to-zoom camera. The Node2D inside it uses _draw() which works reliably on Android. Best of both worlds.
The player script finds the DrawNode via an absolute path:
score_display = get_node_or_null("/root/Main/ScoreDisplay/DrawNode")
And updates scores through method calls:
func add_score(amount: int):
score += amount
if score_display and score_display.has_method("update_player_score"):
score_display.update_player_score(score)
The NPC does the same through the player node, which forwards NPC scores to the display. A bit indirect, but it keeps the score display decoupled from the game entities.
What I Learned
Android rendering quirks are real. CanvasLayer with Label nodes didn’t render on my Xiaomi Mi 11 Lite. _draw() on Node2D did. This is a known Godot 4 issue — some Android GPUs handle the CanvasLayer/Control rendering pipeline differently. The fix is to use _draw() for any HUD that needs to be reliable across devices.
Programmatic animations beat .tres files for small projects. Building SpriteFrames in code means I can tweak frame counts and paths without context-switching to the editor. For a one-evening project, this kept me in the code the whole time.
Elliptical boundaries feel better than rectangles. The math isn’t hard (normalize by semi-axes, check distance² > 1), and the result is a game that feels like it takes place on an island, not in a box.
The sqrt(randf()) trick for uniform distribution in an ellipse is worth knowing. Without the square root, random points cluster near the center. With it, they’re evenly distributed across the area.
Feather effects don’t need a particle system. Eight Sprite2Ds with Tweens give you color variety, directional scatter, rotation, and fade — all the juice you need for a chicken collection effect, with zero GPU particle overhead.
The APK
The final build is 31MB as an Android APK, exported from Godot 4.7 with the GL Compatibility renderer and ETC2/ASTC texture compression. It runs on my phone. The score HUD works. The dino collects chickens. The NPC dino competes. The feathers fly. The island holds.
Not bad for one night.
![]()
The source code is on GitHub. All code is GDScript, all animations are built programmatically, and the entire game fits in five scripts and six scenes.
