You can build a working 2D Java game without libGDX, without a game engine, and without any dependencies beyond the JDK itself.

This guide walks you through the full AWT rendering pipeline, from setting up a JFrame and Canvas to loading a sprite sheet, slicing frames with BufferedImage.getSubimage(), and painting them to the screen at a stable frame rate using Graphics2D. All code examples are tested against Java 21 LTS.

Choosing Your Rendering API: AWT, Swing, or JavaFX

AWT and Swing ship with every JDK through Java 21 with no extra dependencies. JavaFX has been a separate module since Java 11, requiring the OpenJFX SDK added to your module path. For a first 2D game project, AWT with a Canvas and BufferStrategy is the lowest-friction starting point.

FeatureAWT/SwingJavaFX
Threading modelEvent Dispatch Thread + custom game threadJavaFX Application Thread only
Hardware accelerationSoftware by default; pipeline-dependentYes, via Prism rendering engine
API entry pointCanvas + Graphics2DCanvas + GraphicsContext
Recommended useLearning, simple 2D games, no extra depsPolished apps, hardware-accelerated rendering

Pure AWT rendering does not use the GPU by default and will hit performance limits at high sprite counts. That trade-off is acceptable for learning and small projects. JavaFX is cleaner and hardware-accelerated, but it adds dependency management overhead that AWT avoids entirely.

Setting Up the JFrame and Canvas

The JDK (Java Development Kit) includes the tools to compile and run your game. The JVM (Java Virtual Machine) executes your bytecode at runtime. You need JDK 17 or 21 installed before writing any rendering code.

Create a JFrame as your application window and add a Canvas as the drawing surface. Set the canvas size explicitly and call setIgnoreRepaint(true) to stop the AWT event thread from interfering with your active rendering loop.

// Java 21 — JFrame + Canvas setup
JFrame frame = new JFrame("My 2D Game");
Canvas canvas = new Canvas();
canvas.setPreferredSize(new Dimension(800, 600));
canvas.setIgnoreRepaint(true);   // disable AWT repaint manager
frame.add(canvas);
frame.pack();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
canvas.requestFocus();

Pack the JFrame after adding the Canvas so the window respects the canvas’s preferred size. Call setVisible(true) before creating a BufferStrategy, because the strategy requires the canvas to be displayable on screen.

Building a Fixed-Timestep Game Loop

A game loop runs continuously, calling update() to advance game state and render() to paint the current frame. Without a fixed timestep, your game runs faster on faster hardware. That produces inconsistent physics and animation speeds across machines.

We use System.nanoTime() for timing because it has nanosecond resolution and doesn’t drift the way System.currentTimeMillis() can. Run the loop on a dedicated thread, separate from the AWT Event Dispatch Thread (EDT), which handles window events and input. Blocking the EDT causes your window to freeze on resize or close.

// Java 21 — fixed-timestep game loop
public class GameLoop implements Runnable {
    private final long TARGET_FPS = 60;
    private final long NS_PER_TICK = 1_000_000_000 / TARGET_FPS;

    public void run() {
        long lastTime = System.nanoTime();
        long delta = 0;

        while (true) {
            long now = System.nanoTime();
            delta += now - lastTime;
            lastTime = now;

            while (delta >= NS_PER_TICK) {
                update();   // advance game state
                delta -= NS_PER_TICK;
            }
            render();       // paint current frame
        }
    }
}

Start this loop by passing the Runnable to a new Thread and calling start(). The delta time calculation decouples your update rate from render rate, keeping physics consistent at 60 ticks per second regardless of how long rendering takes.

How Does BufferStrategy Prevent Screen Tearing in Java?

A BufferStrategy is a mechanism in Java AWT that manages one or more off-screen buffers to eliminate screen tearing during rendering. Without it, the screen sees partial frames mid-draw, producing visible flicker. This is double buffering: you draw to an off-screen buffer, then flip it to the screen atomically when the frame is complete.

Call canvas.createBufferStrategy(2) after the JFrame is visible. We do this because the canvas must be displayable before AWT can allocate the native buffer resources. The render loop pattern follows a strict sequence every frame:

  1. Call canvas.getBufferStrategy() to get the active strategy.
  2. Call strategy.getDrawGraphics() to get a Graphics object pointing to the off-screen buffer.
  3. Cast it to Graphics2D for access to transforms and rendering hints.
  4. Paint your frame: clear, then draw sprites.
  5. Call graphics.dispose() to release native resources.
  6. Call strategy.show() to flip the buffer to the screen.

We call show() after all draw calls because BufferStrategy only flips the buffer to the screen when explicitly instructed, giving you full control over render timing. Skipping dispose() causes native resource leaks on long sessions. Don’t forget it.

How to Load and Slice a Sprite Sheet with BufferedImage

A sprite sheet is a single image file containing multiple animation frames arranged in a grid. Loading one image and slicing it in memory is far more efficient than loading individual frame files. Here are the steps:

  1. Import javax.imageio.ImageIO and java.awt.image.BufferedImage.
  2. Load the sheet: BufferedImage sheet = ImageIO.read(getClass().getResourceAsStream("/sprites/player.png"));
  3. Calculate each frame’s pixel coordinates based on frame width, height, and grid position.
  4. Call sheet.getSubimage(col * frameWidth, row * frameHeight, frameWidth, frameHeight) for each frame.
  5. Store the returned BufferedImage references in an array indexed by frame number.
// SpriteSheet utility — Java 21
public class SpriteSheet {
    private final BufferedImage sheet;
    private final int frameWidth, frameHeight;

    public SpriteSheet(String path, int fw, int fh) throws IOException {
        sheet = ImageIO.read(getClass().getResourceAsStream(path));
        this.frameWidth = fw;
        this.frameHeight = fh;
    }

    public BufferedImage getSprite(int col, int row) {
        return sheet.getSubimage(col * frameWidth, row * frameHeight,
                                 frameWidth, frameHeight);
    }
}

Cache the sliced frames at startup. Creating a new BufferedImage every frame triggers garbage collection pressure and causes visible frame drops. Slice once, store the array, index into it each tick.

Painting Sprites with Graphics2D

Cast the Graphics object from BufferStrategy to Graphics2D to access transform, composite, and rendering hint APIs. Clear the screen at the start of each render call with g2d.fillRect(0, 0, width, height) to erase the previous frame. Then paint the current animation frame at its world position.

Graphics2D g2d = (Graphics2D) strategy.getDrawGraphics();
g2d.setColor(Color.BLACK);
g2d.fillRect(0, 0, 800, 600);  // clear previous frame

// Set nearest-neighbor for pixel art — prevents blurring on scale
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                     RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);

g2d.drawImage(currentFrame, playerX, playerY, null);
g2d.dispose();
strategy.show();

Use RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR for pixel-art sprites. Without it, Java applies bilinear interpolation on scaled images, blurring your crisp pixel edges. One line. Big visual difference.

The JavaFX Canvas Alternative

JavaFX uses a GraphicsContext object instead of Graphics2D. The API is similar: gc.drawImage(frame, x, y) paints a sprite, gc.clearRect() clears the screen. The threading model differs significantly. JavaFX rendering must run on the JavaFX Application Thread. Use AnimationTimer for your game loop instead of a raw Thread.

From Java 11 onward, add the OpenJFX SDK to your module path and declare requires javafx.graphics; in module-info.java. JavaFX’s Prism rendering engine provides hardware acceleration that AWT’s software pipeline can’t match. The cost is dependency management. For a learning project, AWT wins on simplicity. For a polished game targeting modern hardware, JavaFX is worth the setup.

Common Mistakes That Break Your Rendering

  • Calling repaint() instead of active rendering. This hands control to the AWT repaint manager, breaking your frame timing entirely. Use BufferStrategy and drive rendering yourself.
  • Running the game loop on the EDT. The Event Dispatch Thread handles window events. Blocking it with your game loop causes the window to freeze on resize or close. Always run the loop on a separate thread.
  • Caching sprite frames incorrectly. Slicing getSubimage() inside the render loop creates new objects every frame. Slice at startup and store results in an array.
  • Forgetting graphics.dispose(). Each call to getDrawGraphics() allocates a native graphics context. Not disposing it leaks native resources, and long game sessions will show the consequences.

Can you diagnose a frame rate bug without understanding the EDT? Probably not on the first try. We’ve seen developers spend hours on stuttering games that turned out to be nothing more than a game loop running on the wrong thread.

FAQ: Java 2D Rendering Common Questions

What is the difference between AWT and JavaFX for 2D games? AWT ships with every JDK and uses a software rendering pipeline by default. JavaFX requires the OpenJFX dependency from Java 11 onward and uses a hardware-accelerated Prism engine. AWT is simpler to set up; JavaFX performs better at scale.

How do I prevent screen tearing in Java games? Use BufferStrategy with at least 2 buffers. This draws frames to an off-screen buffer and flips the complete frame to the screen atomically, eliminating partial-frame display.

Is Swing good for game development? Swing works for simple games, but its repaint cycle is managed by the EDT and doesn’t give you precise frame timing. AWT’s Canvas with active rendering and BufferStrategy is a better choice for any game that needs consistent frame rates.

Do I need libGDX to make a 2D game in Java? No. Plain AWT handles sprites, input, and game loops well for small 2D projects. libGDX adds value for larger games needing cross-platform deployment, audio, and physics, but it’s not the right starting point for learning the rendering fundamentals.

Your next step: run the game loop skeleton from this guide, confirm a blank window appears at 60 ticks per second, then swap in your own sprite sheet from a CC0 source and extend the loop with basic keyboard input handling.

Founder and Chief Editor at  |  + posts

Jodie Bird is the founder and principal author of the Java Limit website, a dedicated platform for sharing insights, tips, and solutions related to Java and software development. With years of experience in the field, Jodie leads a team of seasoned developers who document their collective knowledge through the Java Limit journal.