JavaFX Canvas is a practical way to build a small, self-contained 2D game in Java. This guide builds Canvas Dodger, where you steer a player around falling blocks, earn points by surviving, and can pause or restart. The example uses JavaFX 21 with JDK 21 or later and Maven; it separates input, game-state updates, and drawing so you can extend it without turning the frame loop into a tangle.
A Canvas is a JavaFX scene-graph node with a GraphicsContext for issuing drawing commands. Unlike a scene made from a separate node for every object, Canvas uses immediate-mode drawing: the game redraws its current state each frame. It is suited to modest 2D games, prototypes, and learning projects—not a full game engine with built-in physics, collision systems, tile maps, or asset pipelines. OpenJFX Canvas documentation
What you need and which JavaFX version to use
This walkthrough targets JavaFX 21.0.8 and JDK 21 or later. JavaFX 21 is an LTS line identified by OpenJFX; its current patch availability can change, so check the OpenJFX getting-started guide when creating a new project. The same guide identifies JavaFX 26.0.1 as requiring JDK 24 or later; do not combine a JavaFX release with an incompatible JDK.
You should know basic Java classes, collections, lambdas or event handlers, and coordinate geometry. In JavaFX’s default 2D coordinate system, x increases to the right and y increases downward. Use doubles for positions and dimensions, and express movement in units per second rather than pixels per frame.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →#1 Best Overall
Create the Maven project
Maven resolves platform-specific JavaFX dependencies and gives the tutorial one consistent run command. Create a Maven project with this pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>canvas-game</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<javafx.version>21.0.8</javafx.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration>
<mainClass>example.game.GameApp</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
This is a non-modular Maven configuration: do not add a module-info.java to this version of the project. The Maven plugin supplies the JavaFX runtime setup for the project. Create src/main/java/example/game/GameApp.java, then run it from the project directory with mvn clean javafx:run. The command and Maven workflow are documented by OpenJFX.
Create the window and canvas
The canvas is the game’s drawing surface; a StackPane provides room to add overlays or JavaFX controls later. Add this application class:
package example.game;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class GameApp extends Application {
static final double WIDTH = 800;
static final double HEIGHT = 600;
@Override
public void start(Stage stage) {
Canvas canvas = new Canvas(WIDTH, HEIGHT);
StackPane root = new StackPane(canvas);
Scene scene = new Scene(root);
GameController game = new GameController(canvas, scene);
stage.setTitle("Canvas Dodger");
stage.setScene(scene);
stage.show();
canvas.requestFocus();
game.start();
}
public static void main(String[] args) {
launch(args);
}
}
A canvas has one GraphicsContext; retrieve it once with canvas.getGraphicsContext2D(). Canvas dimensions define its logical drawing area, and drawing outside those bounds is clipped. Once attached to a displayed scene, drawing operations must run on the JavaFX Application Thread. GraphicsContext documentation
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Scan for outdated or missing drivers - takes under a minute3Clear out junk files and repair common Windows errorsRank #2
Separate input, updates, and rendering
Keep the game in three parts: input changes intent, update advances state over time, and render draws the current state. The model should own positions, dimensions, and collision bounds; drawing methods should not decide game rules.
For a compact tutorial, the controller below uses rectangle data rather than separate entity classes. As the game grows, move the player and enemy data into classes with their own update and render methods. Keep lifecycle state such as paused and game-over separate from the drawing operations.
Build a frame loop with elapsed time
AnimationTimer calls handle(long now) once per frame while active; now is a nanosecond timestamp. It runs on the JavaFX Application Thread, and the API does not promise a fixed 60 frames per second. Use elapsed time to keep movement approximately consistent when frame delivery varies. AnimationTimer documentation
double dt = (now - lastFrameNanos) / 1_000_000_000.0;
lastFrameNanos = now;
dt = Math.min(dt, 0.05);
update(dt);
render();
The 0.05-second cap is a defensive choice for this example, not a universal rule. It prevents an unusually long delay—such as a breakpoint or a period without CPU time—from moving objects an enormous distance in one update. Variable time steps are simple, but fast objects can still cross a collision area between checks; more demanding simulations can use fixed-size update steps.
The Tool Desk
Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Rank #3
- Learn JavaFX 17: Building User Experience and Interfaces with Java
- ABIS BOOK
- Apress
Track keyboard input and move the player
For continuous movement, keep a set of keys currently held rather than moving only in a key-press handler. Clear the set when the window loses focus so a missed release event does not leave the player moving. JavaFX supports keyboard handlers on scenes and nodes; focus still matters. Node event-handler documentation
The controller in the complete listing accepts left/right arrows or A/D, P to pause or resume, and R to restart after a collision. If a text field or button has focus, it may receive the keystrokes instead. Request focus after showing the stage, as the application class does.
Add falling enemies and collision detection
Each enemy has an x-position, y-position, square size, and downward speed. The controller spawns enemies at random positions along the top edge, advances them by speed * dt, and removes them once they are below the canvas. Removing expired objects prevents the active list from growing indefinitely.
The example uses axis-aligned bounding-box overlap: two rectangles collide when their interiors overlap horizontally and vertically. This is fast and adequate for square blocks, but does not match irregular sprite silhouettes. If a sprite has transparent padding, inset the hitbox slightly; for fast projectiles, smaller simulation steps or swept collision checks may be needed to avoid tunneling.
Render the scene and HUD
Canvas does not retain a separate object for every frame’s drawing. Clear or cover the previous image, draw the background, enemies, player, and then the HUD and overlays in a consistent order. Use clearRect when transparency matters; a filled rectangle is simpler for this game’s opaque black background. The drawing context retains fill, transform, alpha, and other state, so bracket temporary changes with save() and restore().
Complete controller
Save the following as src/main/java/example/game/GameController.java. It contains the game loop, input, updates, collision checks, drawing, pause, and restart. The window class above and this controller are the entire runnable example.
package example.game;
import javafx.animation.AnimationTimer;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.KeyCode;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
public final class GameController {
private final Canvas canvas;
private final GraphicsContext gc;
private final Set<KeyCode> keys = EnumSet.noneOf(KeyCode.class);
private final List<Enemy> enemies = new ArrayList<>();
private final Random random = new Random();
private final AnimationTimer timer;
private double playerX = 380;
private double playerY = 520;
private static final double PLAYER_SIZE = 40;
private long lastFrameNanos;
private double spawnTimer;
private double score;
private boolean paused;
private boolean gameOver;
public GameController(Canvas canvas, Scene scene) {
this.canvas = canvas;
this.gc = canvas.getGraphicsContext2D();
configureInput(scene);
scene.windowProperty().addListener((obs, oldWindow, window) -> {
if (window != null) {
window.focusedProperty().addListener((focusObs, wasFocused, focused) -> {
if (!focused) keys.clear();
});
}
});
timer = new AnimationTimer() {
@Override
public void handle(long now) {
if (lastFrameNanos == 0) {
lastFrameNanos = now;
render();
return;
}
double dt = Math.min((now - lastFrameNanos) / 1_000_000_000.0, 0.05);
lastFrameNanos = now;
update(dt);
render();
}
};
}
public void start() {
timer.start();
}
private void configureInput(Scene scene) {
scene.setOnKeyPressed(event -> {
keys.add(event.getCode());
if (event.getCode() == KeyCode.P && !gameOver) paused = !paused;
if (event.getCode() == KeyCode.R && gameOver) reset();
});
scene.setOnKeyReleased(event -> keys.remove(event.getCode()));
}
private void update(double dt) {
if (paused || gameOver) return;
double direction = 0;
if (keys.contains(KeyCode.LEFT) || keys.contains(KeyCode.A)) direction--;
if (keys.contains(KeyCode.RIGHT) || keys.contains(KeyCode.D)) direction++;
playerX += direction * 300 * dt;
playerX = Math.max(0, Math.min(playerX, canvas.getWidth() - PLAYER_SIZE));
score += dt;
spawnTimer -= dt;
if (spawnTimer <= 0) {
double size = 30 + random.nextDouble() * 25;
double maxX = Math.max(0, canvas.getWidth() - size);
double x = random.nextDouble() * maxX;
double speed = 120 + random.nextDouble() * 140;
enemies.add(new Enemy(x, -size, size, speed));
spawnTimer = Math.max(0.25, 0.8 - score / 120);
}
for (Enemy enemy : enemies) enemy.update(dt);
enemies.removeIf(enemy -> enemy.y > canvas.getHeight() + 100);
Bounds player = new BoundingBox(playerX, playerY, PLAYER_SIZE, PLAYER_SIZE);
for (Enemy enemy : enemies) {
if (player.intersects(enemy.bounds())) {
gameOver = true;
break;
}
}
}
private void render() {
gc.setFill(Color.BLACK);
gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
for (Enemy enemy : enemies) enemy.render(gc);
gc.setFill(Color.DODGERBLUE);
gc.fillRect(playerX, playerY, PLAYER_SIZE, PLAYER_SIZE);
gc.setFill(Color.WHITE);
gc.setFont(Font.font(20));
gc.fillText("Score: " + (int) score, 20, 30);
if (paused) drawCentered("PAUSED");
else if (gameOver) drawCentered("GAME OVER - Press R");
}
private void drawCentered(String message) {
gc.setFont(Font.font(32));
Text measurement = new Text(message);
measurement.setFont(gc.getFont());
double x = (canvas.getWidth() - measurement.getLayoutBounds().getWidth()) / 2;
gc.fillText(message, x, canvas.getHeight() / 2);
}
private void reset() {
enemies.clear();
playerX = (canvas.getWidth() - PLAYER_SIZE) / 2;
playerY = canvas.getHeight() - 80;
score = 0;
spawnTimer = 0;
paused = false;
gameOver = false;
lastFrameNanos = 0;
}
private static final class Enemy {
private final double x;
private double y;
private final double size;
private final double speed;
private Enemy(double x, double y, double size, double speed) {
this.x = x;
this.y = y;
this.size = size;
this.speed = speed;
}
private void update(double dt) {
y += speed * dt;
}
private Bounds bounds() {
return new BoundingBox(x, y, size, size);
}
private void render(GraphicsContext gc) {
gc.setFill(Color.CRIMSON);
gc.fillRect(x, y, size, size);
}
}
}
Start with mvn clean javafx:run. You should see a black 800-by-600 playfield, a blue square near the bottom, and red squares falling from above. Survival time is the score; touching a red square ends the run. P pauses or resumes, and R starts a fresh run after game over.
Load images and sprite sheets
When replacing rectangles with art, put images in src/main/resources/images/ and load them once, not inside the frame loop:
Image playerImage = new Image(
getClass().getResourceAsStream("/images/player.png")
);
gc.drawImage(playerImage, x, y, width, height);
Classpath resource paths are more reliable than assuming a working-directory file such as player.png. For a sprite sheet, use the source-region form of drawImage: source x/y/width/height select the frame, while destination coordinates and dimensions set its on-screen placement. Advance animation frames on their own elapsed-time schedule rather than assuming a particular render rate.
Choose a resize strategy deliberately
This example uses a fixed logical 800-by-600 playfield. A canvas placed in a resizable parent does not by itself make the game world responsive. You can bind canvas dimensions to a layout’s width and height, but resizing clears its contents and changes the coordinate space; redraw on the next frame and reconsider player and spawn bounds.
For a game that should scale consistently, keep a virtual world size and scale the graphics context during world rendering. Render the HUD separately if it should remain at a constant screen size. Avoid repeated canvas resizing unless the game needs it.
Threading, performance, and common failures
- JavaFX runtime components are missing: check
java -version,mvn -version, the JavaFX dependency version, and the configured main class; run viamvn clean javafx:run. Maven and Gradle workflows are covered by the OpenJFX setup guide. - Keyboard input does nothing: ensure the stage is shown and the scene or canvas has focus; another control may have consumed the event.
- The game moves at different speeds: express movement as speed multiplied by elapsed seconds, not a fixed number of pixels per callback.
- Objects jump after a delay: cap the variable time step as shown, or use a fixed timestep for a more demanding simulation.
- The canvas is blank: verify rendering is called, coordinates are in bounds, colors contrast, and resource loading succeeded; inspect exceptions on the JavaFX thread.
- Collision looks wrong: use explicit inset hitboxes for art with transparent margins and remember that bounding boxes approximate irregular shapes.
- The game freezes: keep file I/O, network work, expensive generation, and other long operations out of
handle; remove expired entities and avoid unbounded spawning.
AnimationTimer and visible Canvas updates run on the JavaFX Application Thread. Do not draw from a background thread or mutate the enemy list concurrently with rendering. Background threads are appropriate for expensive loading or preparation; transfer results back to the JavaFX thread before changing visible game state. Avoid per-frame image loading and unnecessary temporary object creation. Profile before adding object pools or other complexity.
When Canvas is the right tool
| Approach | Good fit | Trade-off |
|---|---|---|
| JavaFX Canvas | Custom 2D drawing, modest playfields, prototypes, educational projects | You implement entities, collision, game states, and asset organization. |
| JavaFX shapes and nodes | A small number of independently interactive objects, transforms, CSS, and UI integration | Each object participates in the scene graph; evaluate performance for your workload. |
| JavaFX controls | Menus, forms, settings, and accessible interface elements | Controls are UI components, not a game-rendering architecture. |
| Game framework or engine | Physics, tile maps, advanced audio or particles, controller support, or broader deployment needs | Introduces a different toolset and workflow; alternatives such as FXGL, libGDX, LWJGL, Godot, and Unity are not drop-in substitutes for Canvas. |
Canvas is useful when you want direct control of a small 2D scene and are comfortable writing the surrounding game systems. It is not inherently faster than JavaFX shapes, nor does it guarantee a particular frame rate: performance depends on drawing workload, image sizes, effects, system, drivers, and runtime. For larger games or features such as physics and asset pipelines, evaluate a game framework rather than rebuilding those systems from scratch.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




