Building a Minimal 2048 Clone on Android with a Custom Grid View
A 4×4 grid, a minimal score panel, and a custom view implement the full 2048 interaction loop: spawn, slide, merge, score, and game-over detection. Layout is defined in XML, while swipe handling and game rules live in a GridLayout-backed custom view.
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Score:"/>
<TextView
android:id="@+id/textScoreValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"/>
</LinearLayout>
<dev.sample.twentyfortyeight.BoardView
android:id="@+id/boardView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
TileView.java
package dev.sample.twentyfortyeight;
import android.content.Context;
import android.graphics.Color;
import android.view.Gravity;
import android.widget.FrameLayout;
import android.widget.TextView;
public class TileView extends FrameLayout {
private TextView label;
private int value;
public TileView(Context context) {
super(context);
init();
}
public TileView(Context context, android.util.AttributeSet attrs) {
super(context, attrs);
init();
}
public TileView(Context context, android.util.AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
label = new TextView(getContext());
label.setTextSize(30);
label.setGravity(Gravity.CENTER);
label.setBackgroundColor(0x33ffffff);
LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
lp.setMargins(10, 10, 0, 0);
addView(label, lp);
setValue(0);
}
public int getValue() {
return value;
}
public void setValue(int v) {
value = v;
if (v <= 0) {
label.setText("");
label.setBackgroundColor(0x33ffffff);
} else {
label.setText(String.valueOf(v));
label.setBackgroundColor(colorFor(v));
}
}
public boolean sameAs(TileView other) {
return other != null && value == other.getValue();
}
private int colorFor(int v) {
switch (v) {
case 2: return 0xffeee4da;
case 4: return 0xffede0c8;
case 8: return 0xfff2b179;
case 16: return 0xfff59563;
case 32: return 0xfff67c5f;
case 64: return 0xfff65e3b;
case 128: return 0xffedcf72;
case 256: return 0xffedcc61;
case 512: return 0xffedc850;
case 1024: return 0xffedc53f;
case 2048: return 0xffedc22e;
default:
return Color.WHITE;
}
}
}
BoardView.java
package dev.sample.twentyfortyeight;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Point;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.GridLayout;
import java.util.ArrayList;
import java.util.List;
public class BoardView extends GridLayout {
public interface ScoreSink {
void onReset();
void onGain(int points);
}
private ScoreSink scoreSink;
public void setScoreSink(ScoreSink sink) {
this.scoreSink = sink;
}
private static final int SIZE = 4;
private TileView[][] tiles = new TileView[SIZE][SIZE];
private final List<Point> free = new ArrayList<>();
private float downX, downY;
public BoardView(Context context) {
super(context);
setup();
}
public BoardView(Context context, AttributeSet attrs) {
super(context, attrs);
setup();
}
public BoardView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setup();
}
private void setup() {
setColumnCount(SIZE);
setBackgroundColor(0xffbbada0);
setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent e) {
switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = e.getX();
downY = e.getY();
return true;
case MotionEvent.ACTION_UP:
float dx = e.getX() - downX;
float dy = e.getY() - downY;
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return true;
if (Math.abs(dx) > Math.abs(dy)) {
if (dx < 0) slide(Direction.LEFT);
else slide(Direction.RIGHT);
} else {
if (dy < 0) slide(Direction.UP);
else slide(Direction.DOWN);
}
return true;
}
return false;
}
});
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
int cell = (Math.min(w, h) - 10) / SIZE;
buildCells(cell);
start();
}
private void buildCells(int size) {
removeAllViews();
for (int r = 0; r < SIZE; r++) {
for (int c = 0; c < SIZE; c++) {
TileView t = new TileView(getContext());
addView(t, size, size);
tiles[r][c] = t;
}
}
}
private void start() {
if (scoreSink != null) scoreSink.onReset();
for (int r = 0; r < SIZE; r++) {
for (int c = 0; c < SIZE; c++) {
tiles[r][c].setValue(0);
}
}
spawn();
spawn();
}
private void spawn() {
free.clear();
for (int r = 0; r < SIZE; r++) {
for (int c = 0; c < SIZE; c++) {
if (tiles[r][c].getValue() == 0) free.add(new Point(r, c));
}
}
if (free.isEmpty()) return;
Point p = free.remove((int) (Math.random() * free.size()));
tiles[p.x][p.y].setValue(Math.random() < 0.9 ? 2 : 4);
}
private enum Direction { LEFT, RIGHT, UP, DOWN }
private void slide(Direction dir) {
boolean changed = false;
int gainedTotal = 0;
for (int i = 0; i < SIZE; i++) {
int[] line = readLine(i, dir);
MergeResult res = merge(line);
writeLine(i, dir, res.values);
if (!equals(line, res.values)) changed = true;
gainedTotal += res.gained;
}
if (changed) {
if (scoreSink != null && gainedTotal > 0) scoreSink.onGain(gainedTotal);
spawn();
if (isGameOver()) showGameOver();
}
}
private static class MergeResult {
int[] values;
int gained;
}
private MergeResult merge(int[] raw) {
// compact non-zeros
int[] compact = new int[SIZE];
int idx = 0;
for (int v : raw) if (v != 0) compact[idx++] = v;
int[] out = new int[SIZE];
int write = 0;
int gained = 0;
for (int read = 0; read < idx; ) {
if (read + 1 < idx && compact[read] == compact[read + 1]) {
int merged = compact[read] * 2;
out[write++] = merged;
gained += merged;
read += 2;
} else {
out[write++] = compact[read++];
}
}
MergeResult r = new MergeResult();
r.values = out;
r.gained = gained;
return r;
}
private boolean equals(int[] a, int[] b) {
for (int i = 0; i < SIZE; i++) if (a[i] != b[i]) return false;
return true;
}
private int[] readLine(int index, Direction d) {
int[] out = new int[SIZE];
switch (d) {
case LEFT:
for (int c = 0; c < SIZE; c++) out[c] = tiles[index][c].getValue();
break;
case RIGHT:
for (int c = 0; c < SIZE; c++) out[c] = tiles[index][SIZE - 1 - c].getValue();
break;
case UP:
for (int r = 0; r < SIZE; r++) out[r] = tiles[r][index].getValue();
break;
case DOWN:
for (int r = 0; r < SIZE; r++) out[r] = tiles[SIZE - 1 - r][index].getValue();
break;
}
return out;
}
private void writeLine(int index, Direction d, int[] vals) {
switch (d) {
case LEFT:
for (int c = 0; c < SIZE; c++) tiles[index][c].setValue(vals[c]);
break;
case RIGHT:
for (int c = 0; c < SIZE; c++) tiles[index][SIZE - 1 - c].setValue(vals[c]);
break;
case UP:
for (int r = 0; r < SIZE; r++) tiles[r][index].setValue(vals[r]);
break;
case DOWN:
for (int r = 0; r < SIZE; r++) tiles[SIZE - 1 - r][index].setValue(vals[r]);
break;
}
}
private boolean isGameOver() {
// any empty?
for (int r = 0; r < SIZE; r++)
for (int c = 0; c < SIZE; c++)
if (tiles[r][c].getValue() == 0) return false;
// any mergeable neighbors?
for (int r = 0; r < SIZE; r++) {
for (int c = 0; c < SIZE; c++) {
int v = tiles[r][c].getValue();
if (r + 1 < SIZE && tiles[r + 1][c].getValue() == v) return false;
if (c + 1 < SIZE && tiles[r][c + 1].getValue() == v) return false;
}
}
return true;
}
private void showGameOver() {
new AlertDialog.Builder(getContext())
.setTitle("Game Over")
.setMessage("No more moves.")
.setPositiveButton("Restart", new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface dialog, int which) { start(); }
})
.show();
}
}
MainActivity.java
package dev.sample.twentyfortyeight;
import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;
public class MainActivity extends Activity {
private TextView scoreText;
private int score;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
scoreText = findViewById(R.id.textScoreValue);
BoardView board = findViewById(R.id.boardView);
board.setScoreSink(new BoardView.ScoreSink() {
@Override
public void onReset() {
score = 0;
scoreText.setText(String.valueOf(score));
}
@Override
public void onGain(int points) {
score += points;
scoreText.setText(String.valueOf(score));
}
});
}
}
Implementation details
-
Grid and UI
- GridLayout renders a 4×4 board. Each tile is a TileView (FrameLayout with a centered TextView). Spacing is created with inner margins in TileView.
- Score update are decoupled via BoardView.ScoreSink; MainActivity supplies the implementation too reset and add scores.
-
Input and movement
- A simple OnTouchListener measures the drag delta at finger release. Horizontal magnitude greater than vertical triggers left/right; otherwise up/down.
- Movement and merging use a direction-agnostic ppieline: read a line, compact non-zeros, merge adjacent equals once, then write the result back in directional order.
-
Spawn and end condisions
- A random empty cell receives 2 (90%) or 4 (10%) after every valid move.
- The game ends if there are no empty cells and no adjacent horizontal/vertcial equal pairs; a dialog offers to restart.