Blog

  • Database indexing and query optimization, a deep dive

    Database indexing and query optimization, a deep dive

    If you give me one slow query and an hour, the fix is an index more often than anything else. Indexes are the highest-leverage performance tool in a relational database, and they are also where I see the most confident, wrong intuition. People add an index to every column “just in case,” or they add a multi-column index in the wrong order and wonder why the planner ignores it. This is my mental model for how indexes work and how I decide which ones to build.

    None of this matters if the schema underneath is a mess, so if you have not already, the foundation comes from good design and normalization. Assume here that the model is sound and we are making it fast.

    What an index actually is

    An index is a separate, sorted data structure that lets the database find rows without scanning the whole table. The default in most relational databases is a B-tree, which keeps keys in sorted order and supports both equality lookups and range scans in logarithmic time. That sorted order is the whole point, and it explains almost everything an index can and cannot do.

    Because the keys are sorted, a B-tree index is great at three things. Finding a specific value, finding a range of values, and returning rows already in sorted order so the database can skip a separate sort step. It is useless for the opposite. A query that asks for everything except one value, or that wraps the column in a function the index does not know about, cannot use the sorted structure and falls back to a full scan.

    The cost nobody mentions

    Every index you add makes reads faster and writes slower. That is not a slogan, it is mechanical. When you insert, update, or delete a row, the database has to update every index that covers the affected columns. A table with eight indexes pays for eight little maintenance operations on every write. Indexes also take disk space and memory, and a bloated index that does not fit in cache stops being the speed-up you wanted.

    So I do not index defensively. I index in response to evidence. The right number of indexes is the smallest set that makes your actual queries fast, and finding that set means reading query plans rather than guessing.

    Reading the query plan

    The single most useful skill in database performance is reading EXPLAIN output. It tells you what the planner intends to do, and EXPLAIN ANALYZE tells you what actually happened with real timings. I run it constantly.

    -- See the plan and the real execution numbers
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT id, placed_at
    FROM orders
    WHERE customer_id = 42
      AND status = 'paid'
    ORDER BY placed_at DESC
    LIMIT 20;

    The things I look for, in order. Is there a sequential scan on a big table where I expected an index scan? That is the loudest alarm. How far off is the planner’s estimated row count from the actual row count? A big gap means stale statistics and I run ANALYZE on the table. Is there an expensive sort that an index could satisfy directly? Is the same table being scanned more than once?

    Composite indexes and column order

    Multi-column indexes are where the most points are won and lost. The rule that took me too long to internalize is that column order is everything, and it follows from the sorted structure. An index on (customer_id, status, placed_at) is sorted by customer_id first, then status, then placed_at. That ordering means it can serve a query filtering on customer_id alone, or customer_id and status, or all three. It cannot efficiently serve a query that filters only on status, because status is not the leading column.

    The guideline I use is equality columns first, then the range or sort column last. For the query above, an index on (customer_id, status, placed_at) is close to ideal. The two equality predicates narrow the search, and because placed_at is the final column and already sorted, the database can satisfy the ORDER BY and the LIMIT without a separate sort. One index, no sort step, twenty rows.

    -- Equality columns first, then the column we sort on
    CREATE INDEX idx_orders_customer_status_time
        ON orders (customer_id, status, placed_at DESC);

    Covering indexes and index-only scans

    There is a further trick. If an index contains every column a query needs, the database can answer the query from the index alone and never touch the table. That is an index-only scan, and it can be dramatically faster because it avoids the random reads back into the heap. I get there by including the extra columns the query returns.

    -- INCLUDE adds payload columns without changing the sort key
    CREATE INDEX idx_orders_cover
        ON orders (customer_id, status)
        INCLUDE (placed_at, id);

    I do not do this everywhere, because wider indexes cost more to maintain and store. But for a hot, well-understood query that runs constantly, turning it into an index-only scan is one of the best returns on effort available.

    Indexes the planner will quietly refuse

    A surprising number of indexes go unused because of how the query is written, not how the index is built. The classic mistakes I look for first.

    • Functions on the indexed column. WHERE lower(email) = ‘x’ cannot use a plain index on email. Either store the normalized value, use a case-insensitive type, or build an expression index on lower(email).
    • Leading wildcards. LIKE ‘foo%’ can use a B-tree, but LIKE ‘%foo’ cannot, because the sorted order is useless when the start of the string is unknown.
    • Type mismatches. Comparing a text column to a number forces a cast that can disable the index. Match your types.
    • Low selectivity. An index on a column with two possible values rarely helps, because reading the index plus the heap is often slower than just scanning. The planner knows this and skips it, correctly.

    Partial indexes for skewed data

    One of my favorite tools for real workloads is the partial index, which only covers rows matching a condition. If 95 percent of your orders are completed and you almost always query the small slice that is still pending, indexing only the pending rows gives you a tiny, fast index that stays hot in memory.

    -- Index only the rows we actually search for
    CREATE INDEX idx_orders_pending
        ON orders (placed_at)
        WHERE status = 'pending';

    This keeps the index small, which keeps it fast and cheap to maintain. For tables with heavy skew it is often the difference between an index that fits in cache and one that does not.

    How I actually work

    My loop is boring and it works. Find the slow query from real metrics, not from a hunch. Run EXPLAIN ANALYZE and read it carefully. Identify whether the problem is a missing index, a bad column order, stale statistics, or a query written in a way that defeats indexing. Make one change. Measure again. Repeat until the plan is clean.

    I resist the urge to add five indexes at once, because then I cannot tell which one helped and I have signed up for write overhead I may not need. One change, one measurement. And I revisit the index set periodically, because workloads drift and yesterday’s essential index can become today’s dead weight that only slows down writes. The same care that goes into the schema and the data model goes into keeping indexes honest. Measure, change one thing, measure again. That discipline beats cleverness every time.

  • Scaling databases: read replicas, sharding, and partitioning

    Scaling databases: read replicas, sharding, and partitioning

    Almost every scaling story I have lived through ends at the database. You can cache, you can queue, you can add a hundred stateless app nodes, and it all helps right up until a single primary database is the thing every one of those nodes is waiting on. At that point you have to scale the data layer itself, and that is a different kind of problem because data has weight. Moving compute is easy. Moving and splitting data without losing or corrupting it is where the real engineering lives.

    I think about this as a ladder. Each rung is more powerful and more painful than the last, and you should climb only as high as you actually need.

    First, exhaust the cheap wins

    Before any architectural change I make sure the database is not just doing unnecessary work. The most common cause of a database that feels too small is a database that is missing indexes, running sequential scans on large tables, or being hammered by queries that should have been cached. Connection pool exhaustion masquerades as a scaling problem constantly. A well indexed schema on a properly sized instance handles far more than people expect, and I cover the groundwork in database schema best practices. Do not shard a database that just needs an index.

    Read replicas, the first real lever

    Most applications read far more than they write. Timelines, product pages, dashboards, search results, all reads. So the first structural move is almost always read replicas. You keep one primary that accepts all writes, and you stream its changes to one or more replica copies that serve reads. Now your read capacity scales with the number of replicas while writes stay on the primary.

    The catch is replication lag. A replica is a copy that is always slightly behind, usually by milliseconds but sometimes by seconds under load. This creates a subtle bug class: a user writes something, gets redirected, the read goes to a replica that has not caught up yet, and their own change appears to have vanished. The fix is read your writes routing. After a write, send that user reads to the primary for a short window, or for anything where the user expects to see their own action immediately.

    def get_connection(query_type, just_wrote=False):
        if query_type == "write" or just_wrote:
            return primary_pool.get()
        return replica_pool.get()  # round robin across replicas
    
    # After updating a profile, read from primary briefly
    update_profile(user_id, data)
    profile = read_profile(user_id, just_wrote=True)

    Replicas also give you something beyond capacity. They are a warm standby. If the primary dies, you can promote a replica, which makes replicas part of your availability story and not only your performance story.

    Partitioning, splitting one big table sensibly

    Replicas multiply your read capacity but every replica still holds the entire dataset, and writes still all go to one primary. When a single table grows to hundreds of millions of rows, the table itself becomes the problem. Indexes get deep, vacuum and maintenance get slow, and queries that touch the whole table get sluggish. Partitioning splits one logical table into many physical pieces while keeping a single primary.

    The most useful kind is range partitioning by time. Most large tables are append heavy and time ordered: events, logs, orders, messages. If you partition by month, a query for last week only touches one partition, and dropping old data becomes an instant detach of a whole partition instead of a massive delete.

    CREATE TABLE events (
        id        BIGSERIAL,
        user_id   BIGINT NOT NULL,
        created_at TIMESTAMPTZ NOT NULL,
        payload   JSONB
    ) PARTITION BY RANGE (created_at);
    
    CREATE TABLE events_2026_06 PARTITION OF events
        FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
    
    CREATE TABLE events_2026_07 PARTITION OF events
        FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

    The thing to understand is that partitioning is still one database server. It helps with table size, maintenance, and queries that can prune to a single partition. It does nothing for write throughput on the machine as a whole, because every partition lives on the same box.

    Sharding, the last and heaviest rung

    When a single primary cannot keep up with writes no matter how big the machine is, you have to split the data across multiple independent database servers. That is sharding. Each shard is its own database holding a subset of the data, and there is no single machine that has all of it. This is the move that finally scales writes horizontally, and it is also the move that costs you the most.

    Everything depends on the shard key, the column you use to decide which shard a row lives on. Pick well and most queries hit a single shard. Pick badly and you create hot shards that take all the traffic while others sit idle, or you force queries to fan out across every shard and gather results, which is slow and fragile.

    • Hash based sharding spreads rows evenly by hashing the key. Great for uniform distribution, bad for range queries because related rows scatter everywhere.
    • Range based sharding keeps related keys together, which is good for range scans but tends to create hotspots on the newest range.
    • Directory based sharding keeps an explicit lookup table mapping keys to shards. Most flexible, lets you rebalance, but the directory itself becomes something you have to scale and protect.

    The shard key has to match how you actually query. If you run a multi tenant application, sharding by tenant id is usually ideal because almost every query is already scoped to one tenant, so it naturally lands on one shard. If you ever need a query that does not include the shard key, you are looking at a scatter gather across all shards, and you should design hard to avoid those on hot paths.

    What you give up when you shard

    I want to be blunt about the costs, because sharding gets romanticized. Cross shard joins effectively stop existing. You denormalize or you join in the application layer. Transactions that span shards require distributed transaction machinery that is slow and complex, so you redesign to keep each transaction inside one shard. Globally unique ids need a scheme that does not depend on a single sequence, so you move to UUIDs or a snowflake style generator. Rebalancing when one shard fills up is a genuine project, not a config change. And every one of these costs is permanent. Once you shard, you live with it.

    This is exactly why sharding is the last rung. You climb to it only after replicas, partitioning, caching, and queues have all been pushed as far as they go.

    How I sequence the whole thing

    The path I follow almost every time looks like this. Index and tune until the cheap wins are gone. Add read replicas and route reads off the primary. Partition the giant tables so maintenance and pruning stay sane. Use caching and queues, which I cover in backend scalability patterns, to take pressure off both reads and writes. Only when the write primary itself is the hard ceiling do I shard, and I treat the shard key choice as the most important decision in the entire effort.

    The overarching lesson is that database scaling is a sequence of trade offs, not a single upgrade. Each rung buys capacity and charges complexity. The engineers who get into trouble are the ones who jump straight to the most powerful pattern because it sounds impressive, and then spend two years paying for distributed complexity they could have deferred for a long time with a couple of replicas and a good index.

  • Git workflow and branching best practices

    Git workflow and branching best practices

    I have worked on teams that treated Git like a sacred ritual and teams that treated it like a junk drawer. Neither extreme ships software well. After years of cleaning up messy histories and untangling merge disasters, I have landed on a set of habits that keep things calm. None of them are clever. That is the point.

    Pick one branching model and stop arguing about it

    The model matters less than the agreement. For most product teams I use trunk-based development with short-lived feature branches. You branch off main, you do your work in a day or two, you merge back, you delete the branch. The longer a branch lives, the more it drifts, and the more painful the eventual merge becomes. Long-lived release branches have their place in software that ships on a fixed cadence to customers who cannot update on demand, but for a web app deploying several times a day they are pure overhead.

    What I actively avoid is the elaborate GitFlow setup with develop, release, hotfix, and feature branches all interleaving. I have watched it confuse new hires for weeks. If your deployment is continuous, your branching should be too.

    Write commits that explain the why

    A commit message is a note to whoever is reading the history at 2am during an incident, and that person might be you. The diff already shows what changed. The message needs to capture why. I keep the subject line under about fifty characters, written in the imperative, and I use the body to explain reasoning when the change is not obvious.

    fix: prevent double charge on retry
    
    The payment client retried on a 504 even though the
    charge had already gone through on the gateway side.
    We now key the request with an idempotency token so
    the gateway dedupes it. Closes #482.

    Atomic commits are the other half of this. One logical change per commit. When a commit does three unrelated things, you can never cleanly revert one of them, and bisecting becomes useless. If you find yourself writing “and” in a subject line, that is two commits.

    Rebase your own work, merge shared work

    This is the rule that saves the most pain. Before I open a pull request, I rebase my branch onto the latest main so my changes sit on top of current reality and review is a clean read. But once a branch is shared or a PR is open and others have looked at it, I stop rebasing and merge instead, because rewriting published history forces everyone else to recover their local state.

    • Rebase to tidy local commits before they are public.
    • Use interactive rebase to squash the inevitable “fix typo” and “actually fix it” commits.
    • Never force-push a branch other people are building on.
    • Protect main so nobody can push to it directly.

    Keep main always releasable

    The single most valuable property of a repository is that main always works. If main is green, you can cut a release at any moment, and a broken deploy can be fixed by reverting one merge. I enforce this with required status checks: tests and linting have to pass before a merge button even appears. This connects directly to how I run reviews, which I wrote about in code review best practices. A clean history makes reviews faster, and good reviews keep the history clean. They feed each other.

    Make reverting boring

    When something breaks in production, the fastest safe action is usually to revert, not to debug live. Squash-merging each PR into a single commit on main makes this trivial: one PR is one commit, and reverting it removes the whole feature cleanly. I like squash merges for exactly this reason on application code, though for libraries where individual commit history carries real value I keep the full history.

    Tag your releases so you can always answer “what was running last Tuesday.” A lightweight tag costs nothing and turns a vague question into a one-line answer.

    Some habits that pay off quietly

    • Commit a sensible .gitignore on day one so secrets and build artifacts never enter history. Removing a leaked credential from history is a bad afternoon.
    • Pull with rebase by default to avoid the noise of merge commits on every sync.
    • Keep PRs small. A 200-line PR gets a real review. A 2000-line PR gets a rubber stamp.

    Git rewards discipline more than knowledge. You do not need to memorize the plumbing commands. You need a small set of agreements that everyone actually follows. The same thinking shows up when I design data stores, which I covered in database schema best practices, where a few firm conventions early save enormous cleanup later.

  • REST API design guidelines

    REST API design guidelines

    An API is a promise. Once a client depends on it, every quirk you shipped becomes permanent, because someone out there wrote code against that quirk. I have maintained APIs for years and the ones that aged well all shared the same trait: they were boring and predictable. Here is how I get there.

    Model resources as nouns, use verbs from HTTP

    The URL should name a thing. The method says what you are doing to it. I see endpoints like /getUser and /createOrderNow constantly, and they fight the whole point of HTTP. A clean design uses plural nouns for collections and lets the method carry the action.

    GET    /orders          list orders
    POST   /orders          create an order
    GET    /orders/42       fetch one order
    PATCH  /orders/42       update some fields
    DELETE /orders/42       remove it
    
    GET    /orders/42/items nested resource

    PATCH for partial updates and PUT for full replacement is a distinction worth keeping. Most real updates touch a few fields, so PATCH is what I reach for, and PUT becomes the rare case where the client genuinely owns the entire representation.

    Use status codes the way clients expect

    Return the status code that matches reality. A 200 on a failed request because “we put the error in the body” breaks every generic client and monitoring tool that reads the status line. The set I use covers almost everything:

    • 200 for a successful read or update, 201 when you created something.
    • 400 for malformed input, 422 when the input is well-formed but semantically invalid.
    • 401 when you do not know who they are, 403 when you know and they are not allowed.
    • 404 for a missing resource, 409 for a conflict like a duplicate.
    • 500 only for genuine server faults, never for a client mistake.

    Make errors machine-readable

    An error body should help the calling code react, not just print a string. I return a stable machine code alongside a human message, so clients can branch on the code without parsing prose that I might reword later.

    {
      "error": {
        "code": "insufficient_funds",
        "message": "The card was declined.",
        "field": "payment_method"
      }
    }

    Consistency here matters more than cleverness. Every error in the API should have the same shape, so a client writes one error handler instead of ten. This is the same instinct I bring to logging, which I wrote about in logging and observability best practices: structure beats prose when something else has to read it.

    Plan for versioning before you need it

    You will need to make a breaking change eventually. Decide how before you ship v1. I put the version in the path, /v1/orders, because it is visible, cacheable, and trivial to route. Header-based versioning is more elegant on paper and more annoying in practice when someone is debugging with curl. Whatever you pick, the rule is that you never break an existing version. Additive changes like new optional fields are fine. Removing a field or changing its type is a new version.

    Paginate and filter from day one

    Any collection that can grow will grow, and a GET that returns ten thousand rows will eventually time out and take a database with it. I add pagination to every list endpoint at the start, even when the data is tiny, because retrofitting it later is a breaking change. Cursor-based pagination handles large, shifting datasets better than offset, since offset drifts when rows are inserted mid-scroll.

    • Return a stable cursor and a clear “has more” signal.
    • Allow filtering with query parameters, and document exactly which fields are filterable.
    • Cap the page size server-side so a client cannot ask for everything at once.

    Be strict about what you accept, generous about what you return

    Validate input hard at the boundary and reject anything malformed with a clear 400 or 422. The closer to the edge you catch bad data, the less it can corrupt downstream, which ties straight back to the constraints I rely on in database schema best practices. On the output side, keep responses stable and predictable so clients can trust the shape. An API that is strict at the door and consistent on the way out is one people enjoy building against, and that good will is what gets your platform adopted.

  • Getting started with the Godot 4 game engine

    Getting started with the Godot 4 game engine

    I picked up Godot for a small game jam two years ago and never went back to anything else for 2D work. I had used Unity before, and the contrast was immediate. Godot opens in under three seconds, the editor itself is one self contained download of about a hundred megabytes, and the whole thing is open source under the MIT license. That last part stopped mattering to me until the day Unity announced runtime fees and I realized how much it matters to own your tools. This post is the orientation I wish someone had handed me on day one.

    What Godot actually is

    Godot is a free, open source engine for 2D and 3D games. It runs on Windows, macOS, and Linux, and it exports to all the desktop platforms plus web, Android, and iOS. There is no account to create, no license server, no splash screen on your finished game. You download the editor, unzip it, and run it. I keep three different versions on disk because switching is just a different executable.

    The version that changed everything was Godot 4. It brought a rewritten rendering backend built on Vulkan, real global illumination for 3D, and a much faster scripting layer. If you are starting today, start on Godot 4. Most tutorials older than that target Godot 3, and the API differences will trip you up constantly.

    Nodes and scenes are the whole mental model

    Everything in Godot is a node. A sprite is a node. A sound player is a node. A collision shape is a node. You arrange nodes into a tree, and a saved tree is called a scene. That is genuinely the entire architecture, and once it clicks you stop fighting the engine.

    The part that took me a week to internalize is that scenes nest. A coin is a scene with a sprite, a collision area, and a sound. Your level is a scene that contains dozens of coin scenes as children. Your whole game is a scene that swaps levels in and out. There is no separate concept of a prefab like in other engines, because every scene is already reusable by design.

    Here is how I think about which node to reach for:

    • Node2D is the base for anything with a position, rotation, and scale in 2D space.
    • CharacterBody2D is what you want for a player or enemy that moves and collides but that you control directly in code.
    • Area2D detects overlaps without pushing things around, perfect for pickups, triggers, and hurtboxes.
    • Sprite2D and AnimatedSprite2D draw your art.
    • CanvasLayer holds your UI so it stays fixed while the camera moves.

    GDScript is not Python, but it rhymes

    Godot ships with its own scripting language called GDScript. It looks a lot like Python, with indentation based blocks and dynamic typing, but it is built into the engine so it knows about nodes and the scene tree natively. You can also use C# if you need it, and there is a C++ path through GDExtension for performance critical code, but I have shipped real games entirely in GDScript and never hit a wall.

    Every script extends a node type. The two functions you will write constantly are _ready, which runs once when the node enters the tree, and _process, which runs every frame. Here is a tiny script that spins a sprite and prints a greeting:

    extends Sprite2D
    
    # How fast we rotate, in radians per second
    var spin_speed := 2.0
    
    func _ready() -> void:
        print("Sprite is ready")
    
    func _process(delta: float) -> void:
        # delta is the time since the last frame, so motion stays
        # consistent no matter the framerate
        rotation += spin_speed * delta
    

    Notice the optional type hints with the colon and the walrus style colon equals for inferred types. I strongly recommend typing your variables. The editor gives you better autocomplete, and the compiler catches mistakes you would otherwise find at runtime.

    The signal system, or how nodes talk

    Godot leans hard on a publish and subscribe pattern called signals. A button emits a pressed signal. An Area2D emits a body_entered signal when something overlaps it. You connect those signals to functions, either by dragging in the editor or in code. This keeps your nodes decoupled, because a coin does not need to know anything about the player, it just announces that it was touched.

    You can connect a signal from code like this:

    extends Area2D
    
    func _ready() -> void:
        body_entered.connect(_on_body_entered)
    
    func _on_body_entered(body: Node2D) -> void:
        print("Something entered: ", body.name)
        queue_free()
    

    The queue_free call removes the node safely at the end of the frame. Get comfortable with signals early. Fighting against them by polling state every frame is the most common beginner mistake I see.

    The editor layout you will live in

    The editor has a Scene dock on the left that shows your node tree, a FileSystem dock below it for your project files, an Inspector on the right for tweaking properties, and the viewport in the middle. The bottom panel is where the output console, the debugger, and the animation editor live. I dock the debugger open at all times because the stack traces are good and the remote scene tree lets you inspect a running game live.

    One habit that paid off: keep your project organized into folders from the start. I use a scenes folder, a scripts folder, an assets folder for art and audio, and an autoload folder for global singletons. Godot does not impose a structure, so the discipline is on you.

    Autoloads for global state

    When you need something accessible from everywhere, like a score manager or an audio bus, you register a scene or script as an autoload in the project settings. It becomes a singleton that loads before everything else and persists across scene changes. This is how I handle the player inventory, settings, and save data. Do not overuse it, but for genuinely global concerns it is the cleanest tool Godot gives you.

    Exporting your game

    When you are ready to share, you install export templates once and then pick a preset for each platform. Web export is my favorite for jams because you upload a folder and anyone can play in a browser instantly, no download required. Desktop exports produce a single executable. The whole process takes a couple of minutes once it is set up.

    Where to go next

    The fastest way to learn Godot is to build something tiny and finish it. Do not start with your dream RPG. Make a game where one thing moves and one thing happens when you touch another thing. Once you have the engine basics down, the natural next step is putting them together into something playable. I wrote a full walkthrough of exactly that in building a 2D game in Godot with GDScript, where we wire up a moving character, collisions, and a score from scratch.

    Godot rewards people who ship. The engine gets out of your way, the community is generous, and the documentation is genuinely excellent. Download it, open a blank project, and add your first node today.

  • Building a 2D game in Godot with GDScript

    Building a 2D game in Godot with GDScript

    The best way to learn an engine is to finish a small game, not to read forever. So in this post we build a complete, if tiny, 2D game in Godot 4. A player moves around, collects coins to raise a score, and dies if an enemy touches them. By the end you will have touched every system you need for most 2D projects. If you have never opened Godot before, read my getting started with the Godot 4 game engine guide first, because I assume you know what a node and a scene are here.

    Setting up the project and the player scene

    Create a new project with the Forward Plus renderer. The first scene we build is the player. Add a CharacterBody2D as the root, then give it three children: a Sprite2D for the art, a CollisionShape2D for the physics body, and a Camera2D so the view follows the player. Set the collision shape to a capsule or a rectangle that roughly matches your sprite. Save the scene as player.tscn.

    CharacterBody2D is the right base here because it gives us a built in velocity and a move_and_slide method that handles collision response without us doing the vector math by hand.

    Moving the player

    Before we write movement code, define some input actions. Open Project Settings, go to the Input Map tab, and add four actions named move_up, move_down, move_left, and move_right. Bind each to the arrow keys and the WASD keys. Mapping inputs to named actions instead of hardcoding keys means you can support gamepads and remapping later without rewriting anything.

    Now attach a script to the player root. Here is the movement logic:

    extends CharacterBody2D
    
    # Pixels per second
    @export var speed: float = 220.0
    
    func _physics_process(delta: float) -> void:
        # Build a direction vector from the four input actions.
        # get_axis returns a value from -1 to 1 for each pair.
        var direction := Vector2.ZERO
        direction.x = Input.get_axis("move_left", "move_right")
        direction.y = Input.get_axis("move_up", "move_down")
    
        # Normalize so diagonal movement is not faster
        if direction.length() > 1.0:
            direction = direction.normalized()
    
        velocity = direction * speed
        move_and_slide()
    

    A few things worth calling out. We use _physics_process instead of _process because anything that moves a physics body should run on the fixed physics timestep. The @export annotation makes speed editable in the Inspector, so I can tune it without touching code. And normalizing the direction stops the classic bug where moving diagonally is about forty percent faster than moving straight.

    Making a collectible coin

    Create a second scene with an Area2D as the root. Area2D detects overlaps without physically blocking anything, which is exactly what a pickup needs. Give it a Sprite2D and a CollisionShape2D. Save it as coin.tscn and attach this script:

    extends Area2D
    
    # Fired when the player grabs this coin
    signal collected
    
    func _ready() -> void:
        body_entered.connect(_on_body_entered)
    
    func _on_body_entered(body: Node2D) -> void:
        if body.is_in_group("player"):
            collected.emit()
            queue_free()
    

    We declare our own signal called collected and emit it when a player walks in. The coin does not know what a score is, and it should not. It simply announces that it was picked up and removes itself. To make the group check work, select the player node, open the Node dock next to the Inspector, switch to Groups, and add it to a group named player.

    Tracking the score with a global

    The score needs to outlive any single scene, so register it as an autoload. Create a script called game_state.gd:

    extends Node
    
    var score: int = 0
    
    signal score_changed(new_score: int)
    
    func add_points(amount: int) -> void:
        score += amount
        score_changed.emit(score)
    
    func reset() -> void:
        score = 0
        score_changed.emit(score)
    

    Register it in Project Settings under the Globals tab with the name GameState. Now any script in the game can call GameState.add_points and listen for the score_changed signal to update the display. This is the cleanest way to share state in Godot without tangling your scenes together.

    Wiring up the level and the UI

    Build a level scene. Drop in an instance of the player, scatter several coin instances around, and add a CanvasLayer with a Label inside it for the score. The CanvasLayer keeps the label pinned to the screen even as the camera follows the player. Attach a small script to the label:

    extends Label
    
    func _ready() -> void:
        GameState.score_changed.connect(_on_score_changed)
        _on_score_changed(GameState.score)
    
    func _on_score_changed(new_score: int) -> void:
        text = "Score: " + str(new_score)
    

    For each coin to actually add to the score, connect its collected signal to a handler in the level script that calls GameState.add_points(10). You can do this in the editor by selecting a coin and using the Node dock, or in code by looping over the coins in _ready. I prefer code when there are many instances, because connecting fifty coins by hand in the editor is tedious and error prone.

    Adding an enemy and a game over

    An enemy can be as simple as another Area2D that moves back and forth. When it overlaps the player, the game ends. Here is a bare bones patroller:

    extends Area2D
    
    @export var move_speed: float = 90.0
    var _direction: int = 1
    
    func _ready() -> void:
        body_entered.connect(_on_body_entered)
    
    func _physics_process(delta: float) -> void:
        position.x += move_speed * _direction * delta
        # Flip direction at the patrol edges
        if position.x > 600 or position.x < 200:
            _direction *= -1
    
    func _on_body_entered(body: Node2D) -> void:
        if body.is_in_group("player"):
            get_tree().change_scene_to_file("res://scenes/game_over.tscn")
    

    The change_scene_to_file call tears down the current scene and loads a new one. Build a simple game over scene with a Label and a button that resets the score and loads the level again. Remember to call GameState.reset on that button so the next run starts clean.

    Polish that punches above its weight

    A working game and a game that feels good are different things. The cheapest wins I know of:

    • Add a short sound on coin pickup. Even a single beep makes collecting feel real.
    • Give the camera a small smoothing value so it eases toward the player instead of snapping.
    • Play a quick scale tween on the coin before it disappears so it pops rather than vanishes.
    • Add a subtle screen shake when the player dies. Twenty minutes of work, huge perceived quality.

    Tweens in Godot 4 are created with create_tween and are great for this kind of juice without writing per frame animation code.

    Where this scales to

    You now have the full loop: input, movement, collision, collectibles, global state, an enemy, and scene transitions. Almost every 2D game is a more elaborate version of these same pieces. A platformer adds gravity and jumping to the movement script. A shooter spawns bullet scenes on a timer. A puzzle game swaps the physics for a grid. The architecture you just built carries over directly.

    Finish this game, then break it on purpose. Add a second enemy type, a high score that persists to disk, a title screen. Shipping small and iterating is how you actually learn the engine, far more than any tutorial including this one.