rhys/byjoio

Rhys · developer portfolio

Java backend engineer
in the making.
Web developer already.

Java for depth. The web for clients. Enterprise automation for a living. Everything here is real work with the decisions left in - including the ones I would make differently now.

Three ways in. Pick whichever matches why you came.

Day
Job hunting - junior Java / Spring Boot roles
Night
OU degree, plus a Spring Boot bootcamp
Between
byjoio client work, and Mexveng

Projects · Java

Games in Java

Three of them, in the order I built them. Each one added exactly one hard new thing, and the last one is still going. All three are Java and LibGDX with hand-drawn assets, and every line is public.

  1. 01

    JPong

    Finished · playable

    Pong, from scratch. The game loop, a velocity vector, and collision that has to feel right rather than merely be correct.

    Learned the shape of a finished game

  2. 02

    JPacman

    Finished

    A maze chase where the ghosts genuinely hunt you: breadth-first search across the map, plus chase, frightened and eaten states.

    Learned pathfinding and state machines

  3. 03

    Mexveng

    In progress

    A 2D medieval RPG, and the first project big enough that architecture decisions have consequences I have to live with.

    Learning how to sustain a system over time

Decision: finish small things before starting big ones

Pong, then Pac-Man, then the RPG. Finishing matters more than the size of the thing finished: an RPG can stay half-built forever, but Pong either plays or it doesn't, and that verdict teaches you something a permanent work-in-progress never will. I could have started with Mexveng. I'd still be debugging it.

Project · Java

Mexveng

A 2D medieval RPG in Java and LibGDX. It exists because tutorials end before the hard parts begin - I wanted a codebase big enough that architecture decisions have consequences.

Top-down world, tile maps, entities, combat, dialogue. Solo project, long-running, and deliberately unglamorous under the hood: plain Java, explicit state, no magic. When something breaks at 11pm I want to read the code and know why.

It's not my first finished game in Java either: JPong and JPacman came before it, and taught me enough to want something bigger.

Decision: why Java and LibGDX, not a full engine

I'm moving into Java backend work, so every hour in Mexveng is an hour in the language I'm betting my career on. LibGDX gives me a game loop and rendering, then stays out of the way - the architecture is mine to get right or wrong, which is exactly the practice I'm here for. Unity would have shipped a game faster and taught me less.

Decision: explicit over clever

Mexveng has a house rule: if a line needs a comment to explain what it does, rewrite the line. InputHandler could bind keys through a lookup table and loop over it. Instead there's one named method per key and one explicit if per key in checkKeyboardInput():

if (keyPressedA()) { dx = -1.0F; }
if (keyPressedD()) { dx = 1.0F; }

More lines than a table-driven version, but grep for keyPressedE() and you land directly on the line - not in a binding table you have to trace back through indirection.

Feel the movement

This is not Mexveng running in your browser - a smaller box, and one deliberate upgrade. Player.java below reverts both axes together when you hit a wall, so diagonal movement stops dead. This demo runs the fix the walkthrough names: each axis resolves on its own, so you glide along walls instead of sticking to them. Read the code, then feel the difference.

There are thirteen runes hidden around the room, one in every pocket of it. Collecting the lot means hugging most of the walls in here, which is the fastest way to feel what the fix changed. The score lives in memory only - refresh and it starts over.

Runes collected: 0 of 13

Click or tab to the canvas, then WASD or arrow keys to move and collect. Touch: use the pad.

The real thing, in Java

An excerpt from the real Player.java - trimmed to the movement-relevant fields and methods; the full class also tracks inventory and coins. InputHandler is the one place every input in the game passes through: movement, interact, inventory, dialogue confirm. This follows just the movement path.

public class Player {

    private final InputHandler inputHandler;
    private float dx;
    private float x;
    private float dy;
    private float y;
    private float speed;

    private final Rectangle playerHitbox;

    public Player() {
        speed = 125.0F;
        playerHitbox = new Rectangle(x, y, 32.0F, 32.0F);
        inputHandler = new InputHandler();
    }

    public void update(float deltaTime, Array collisionRects) {
        handleInput();
        x += dx * speed * deltaTime;
        y += dy * speed * deltaTime;
        playerHitbox.setPosition(x, y);
        checkCollision(collisionRects, deltaTime);
    }

    private void checkCollision(Array collisionRects, float deltaTime) {
        for (Rectangle rectangle : collisionRects) {
            if (playerHitbox.overlaps(rectangle)) {
                x -= dx * speed * deltaTime;
                y -= dy * speed * deltaTime;
            }
        }
    }

    private void handleInput() {
        dx = inputHandler.dx();
        dy = inputHandler.dy();
    }
}
  1. Movement is somebody else's job

    handleInput() reads exactly two numbers off InputHandler, already normalised. Player never touches a key code - swap keyboard for a gamepad or a replay file and only InputHandler changes.

  2. Move first, resolve second

    update() runs in a fixed order: read input, integrate position on both axes, sync the hitbox, then check collision. Every step trusts the one before it - nothing interleaves.

  3. Collision rolls back the whole move

    checkCollision() loops every solid rectangle and, on any overlap, reverts both axes by the same amount that was just added - not just the blocked one. Walk diagonally into a wall and you stop dead instead of sliding along it. Axis-separated collision fixes that - it's what the demo above is running, and the next change I'd make to this class.

  4. One number, used twice

    speed is set once, in the constructor. Both the move and the possible rollback multiply it by the same deltaTime - correct, but recomputed rather than cached. Small, but it's the kind of thing I'd hoist into a local once I'm past "does it work."

Games · 01 · finished

JPong

Two paddles, one ball, sound on every hit. The ball carries a velocity vector and the whole game is what happens when that vector meets something solid. Small enough to finish in a few evenings, which was the point - I wanted the shape of a complete game loop in my hands before attempting a big one.

Ball.java, the whole game in one class

Trimmed to the physics and reformatted for this page; the full class is in the repo.

public class Ball {

    private static final float INITIAL_SPEED = 200.0F;
    private static final float INITIAL_X = 480.0F;
    private static final float INITIAL_Y = 320.0F;
    private static final float RADIUS = 8.0F;
    private static final float MIN_Y = 32.0F;
    private static final float MAX_Y = 608.0F;

    private final Vector2 position;
    private final Vector2 velocity;
    private final Rectangle bounds;

    public void update(float deltaTime, Rectangle paddle1Bounds, Rectangle paddle2Bounds) {
        position.add(velocity.cpy().scl(deltaTime));
        bounds.x = position.x - RADIUS;
        bounds.y = position.y - RADIUS;

        if (bounds.y <= MIN_Y) {
            velocity.y *= -1;
            position.y = MIN_Y + RADIUS;
        }

        if (bounds.y + bounds.height >= MAX_Y) {
            velocity.y *= -1;
            position.y = MAX_Y - RADIUS;
        }

        if (bounds.overlaps(paddle1Bounds)) {
            playSound.paddleHit1.play(0.7F);
            velocity.x *= -1;
            position.x = paddle1Bounds.x + paddle1Bounds.width + RADIUS;
        }

        if (bounds.overlaps(paddle2Bounds)) {
            playSound.paddleHit1.play(0.7F);
            velocity.x *= -1;
            position.x = paddle2Bounds.x - RADIUS;
        }
    }

    public void reset() {
        position.x = INITIAL_X;
        position.y = INITIAL_Y;

        velocity.x = INITIAL_SPEED;
        velocity.y = INITIAL_SPEED;

        velocity.x *= MathUtils.randomSign();
        velocity.y *= MathUtils.randomSign();
    }
}
  1. Velocity is a vector, not two numbers

    position.add(velocity.cpy().scl(deltaTime)) is the entire movement model. The cpy() matters: scaling the velocity in place would shrink the ball's speed to nothing within a second. Copy, scale the copy, add it.

  2. Bouncing is one sign flip

    Hit the top or bottom and the y component reverses. No angle maths, no reflection vectors - for an axis-aligned wall, multiplying by -1 is the whole reflection. The simplest thing that is also correct.

  3. Snap out, don't just turn around

    Every bounce also repositions the ball to the surface it hit. Without that line, a fast ball can end a frame already inside the wall, flip again next frame, and vibrate in place. Reversing direction is the easy half; leaving the thing you hit is the half that bites.

  4. Serve randomly, or it's a script

    MathUtils.randomSign() on both axes means four possible openings instead of one. Two lines to stop every round starting identically.

The tileset

Drawn by hand, like everything else in these games. Shown enlarged; the real tiles are 16px.

Hand-drawn JPong tileset
JPong tileset

Games · 02 · finished

JPacman

The one I'd show first in an interview. Ghosts don't wander - they run a breadth-first search across the maze and take the first step of the shortest path to you. Three modes on top of that: chase, frightened (run for the corner), and eaten (head home to respawn). The maze is a Tiled map, with walkability read from tile properties rather than hardcoded.

A minute of the real thing. Watch the ghosts fall into single file down a corridor - four independent searches converging on the same shortest path, which is what BFS looks like from the outside.

Watch the search

The same breadth-first search from Ghost.java, running live. Click any open tile to move the target and the ghost re-paths to it. Turn on show the search to see every tile BFS visited before it found the route - that spreading flood is why the first path it finds is always the shortest.

visited shortest path

Click an open tile to move the target. Keyboard: tab to the canvas, then arrow keys.

The search itself

Ghost.findNextTile() - reformatted for this page, same logic as the repo. It answers one question: from here, what is the very next tile on the shortest walkable route to the player?

private int[] findNextTile(int startColumn, int startRow,
                           int goalColumn, int goalRow,
                           TiledMapTileLayer wallsLayer) {
    Queue<int[]> queue = new LinkedList<>();
    Map<String, int[]> cameFrom = new HashMap<>();

    int[] startTile = {startColumn, startRow};
    queue.offer(startTile);
    cameFrom.put(startColumn + ", " + startRow, null);

    while (!queue.isEmpty()) {
        int[] currentTile = queue.poll();
        int currentColumn = currentTile[0];
        int currentRow = currentTile[1];

        if (currentColumn == goalColumn && currentRow == goalRow) {
            break;
        }

        int[] upNeighbour = {currentColumn, currentRow - 1};
        int[] downNeighbour = {currentColumn, currentRow + 1};
        int[] leftNeighbour = {currentColumn - 1, currentRow};
        int[] rightNeighbour = {currentColumn + 1, currentRow};
        int[][] neighbours = {upNeighbour, downNeighbour, leftNeighbour, rightNeighbour};

        for (int[] neighbour : neighbours) {
            int neighbourColumn = neighbour[0];
            int neighbourRow = neighbour[1];

            if (!isTileWalkable(neighbourColumn, neighbourRow, wallsLayer)) {
                continue;
            }

            String neighbourKey = neighbourColumn + ", " + neighbourRow;
            if (cameFrom.containsKey(neighbourKey)) {
                continue;
            }

            cameFrom.put(neighbourKey, currentTile);
            queue.offer(neighbour);
        }
    }

    int[] tile = {goalColumn, goalRow};
    String tileKey = goalColumn + ", " + goalRow;

    while (cameFrom.get(tileKey) != null
        && (cameFrom.get(tileKey)[0] != startColumn
         || cameFrom.get(tileKey)[1] != startRow)) {
        tile = cameFrom.get(tileKey);
        tileKey = tile[0] + ", " + tile[1];
    }

    return tile;
}
  1. A queue and a trail of breadcrumbs

    Two structures do all the work. The queue holds tiles waiting to be explored; cameFrom records which tile each one was reached from. The start tile is stored with a null predecessor - that null is what tells the reconstruction later where to stop.

  2. First-in, first-out - so the first hit is the shortest

    This is the whole reason BFS works. A queue explores every tile one step away before any tile two steps away, so the first time it reaches the goal, it cannot have taken a longer route. Swap the queue for a stack and it still finds the player - just via a ridiculous scenic tour.

  3. Four neighbours, walls skipped, nothing twice

    Two guards keep it honest. Unwalkable tiles are skipped, so ghosts never path through walls - and walkability comes from the Tiled tile's own properties, so the level designer controls it, not the code. Already-seen tiles are skipped too, which both prevents infinite loops and protects the shortest route already recorded.

  4. Walk the breadcrumbs back

    BFS found the goal, but the ghost needs the first step, not the last. So it starts at the goal and follows cameFrom backwards until the tile it came from is the start - and that tile is where the ghost moves next. Recalculated every frame, which is wasteful and completely fine at maze scale.

Every sprite drawn by hand

No asset packs. Four ghosts with a frame per direction, both scare states, the player, and the maze tileset. Shown at 4x - they are 16px tiles.

Hand-drawn cyan and red ghost sprite
Ghost one
Hand-drawn blue and yellow ghost sprite
Ghost two
Hand-drawn green and pink ghost sprite
Ghost three
Hand-drawn orange and green ghost sprite
Ghost four
Hand-drawn frightened ghost sprite
Frightened
Hand-drawn eaten ghost eyes sprite
Eaten
Hand-drawn player sprite sheet, mouth open and closed
Player, two frames
Hand-drawn JPacman maze tileset
Maze tileset

Client work · byjoio

Static first

byjoio is my freelance practice. Clients come to me running on nothing but a social media page, an outdated site from years ago, or no online presence at all. They pay once and leave with a site they own outright - nothing to renew, nothing to subscribe to.

The build fits the client: semantic HTML by default, React when a project genuinely needs it. Vercel for hosting, Formspree for forms, analytics set up in the client's own account. At handover the client owns every piece - if I disappear tomorrow, their site doesn't.

The pay-once model runs deeper than client sites - I productised it. byjoio Invoicing is invoicing software you buy once and own: branded PDF invoices, time tracking by client, invoice status, full data export. No subscription, ever - the same handover promise clients get, turned into a product.

Drag the handle

A composite sketch, not a specific client - the shape of a typical rebuild. Left is what arrives; right is what leaves.

What arrives · template build

HomeAboutServicesGalleryBlogFAQContact
stock hero · 2.4 MB
stock
stock
stock
Subscribe to our newsletter! ×
We value your privacy · Accept all

What leaves · byjoio rebuild

Business name WorkReviewsContact Call now
One line that says what they do,
and where they do it.
Real photos of real work. A phone number above the fold. Nothing else competing for the tap.
★★★★★ Real reviews, shown where people look
✓ What they do ✓ Where they work ✓ How to reach them
Opening hours Phone Areas covered
What often arrives
A builder runtime, jQuery, a handful of tracking scripts, and a home page trying to be everything at once
What leaves
One clear message, real photos, a phone number above the fold, nothing else competing for the tap

Illustrative, not measured - a sketch of the shape a rebuild takes, not benchmark numbers from a specific project.

Decision: AI pair programming, in the open

Every byjoio build is pair programmed with Claude Code - my input at every stage, my review on every line, my name on the invoice. It makes delivery faster without changing who is accountable for the result. I'd rather say that plainly than pretend otherwise: clients are paying for judgement, and the judgement is mine.

Decision: keep it simple

The honest reason is mostly that: keep it simple. Most small businesses need a handful of pages that don't change often - they don't need a database, a login, or a monthly bill just to stay online. Static means nothing to patch, nothing to breach, and pages that are fast because there's nothing slow to load in the first place. When a client genuinely needs to edit content every week, that changes the answer - React or a CMS becomes the right call, not a compromise.

Client names and links live with the clients, not on my portfolio. Ask me and I'll walk you through specific builds.

Client work · pattern

Forms without a backend

Every byjoio site needs a contact form. None of them need a server. This is the pattern that ships on all of them: native validation first, a honeypot for spam, Formspree for delivery, and a fallback that still works when JavaScript doesn't.

Try it

Live demo - nothing is sent anywhere.

There is a fourth field on this form that you cannot see. Spam bots fill it in; humans never do. That is the whole spam filter.

The code that ships

The actual submission handler from the byjoio pattern, step by step.

const form = document.querySelector('.contact-form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  if (form.website.value !== '') {
    return;
  }

  if (!form.reportValidity()) {
    return;
  }

  const button = form.querySelector('[type=submit]');
  button.disabled = true;

  try {
    const response = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form),
      headers: { Accept: 'application/json' },
    });
    if (!response.ok) {
      throw new Error(String(response.status));
    }
    form.hidden = true;
    document.querySelector('.form-sent').hidden = false;
  } catch {
    form.submit();
  }
});
  1. The honeypot

    The hidden "website" field. Bots auto-fill every input; humans can't see this one. If it has a value, drop the submission silently - no captcha, no friction for real visitors, and in practice it stops nearly all form spam on small sites.

  2. Let the browser validate

    required and type="email" in the markup already know the rules. reportValidity() shows the browser's own messages, in the visitor's own language, with correct focus handling. Reimplementing that in JavaScript is work I'd have to test and maintain for a worse result.

  3. Disable before sending

    A slow connection plus an impatient thumb means double submissions - and a client asking why enquiries arrive twice. Disabling the button before the request closes the gap.

  4. Fetch, with a real fallback

    Success swaps the form for a confirmation without leaving the page. But the catch block is the important line: if fetch fails for any reason, form.submit() falls back to a plain HTML post to Formspree's hosted page. The form works with JavaScript broken, blocked, or absent - the enhancement is optional, the function isn't.

Decision: why not just build a backend? I know how.

Because the boring solution wins. A mail server I run is a mail server the client depends on me for, forever. Formspree in the client's own account costs them nothing at their volume, survives me, and turns a server-shaped problem into a paragraph of JavaScript. Knowing how to build it is exactly what lets me decide not to.

Deep dive · automation

The same process, twice

I have production experience automating business processes in Blue Prism, and I'm moving toward building services in Java. The clearest way to show the difference is to solve one problem both ways.

The problem: 200 supplier invoices arrive by email every day. Each needs validating, coding, and entering into a finance system. Some days it's 12. Some days it's 900.

A representative example, not a specific process I worked on.

  1. 1 · The interface

    Blue Prism

    The bot drives the finance system's own screens - the same buttons and fields a human uses. No API required, and that is RPA's superpower: it automates systems nobody can change. Most enterprise software is exactly that.

    Java service

    The service needs a real seam: an API, a database, a file drop. If the finance system offers none, Java cannot start - which is exactly why problems like this end up as RPA work in the first place.

  2. 2 · Where the logic lives

    Blue Prism

    Process diagrams and object actions inside a proprietary tool. Readable to trained eyes, but diffing two versions of a process means eyeballing flowcharts. Review culture has to fight the tooling.

    Java service

    Plain text. Diffable, reviewable line by line, testable in isolation, refactorable with confidence. The entire ecosystem of software quality practice - version control, code review, CI - applies with no translation.

  3. 3 · When it breaks

    Blue Prism

    The vendor redesigns one screen and selectors break without warning. You find out from the next queue report. The mitigations are real engineering: work queues, retries, exception screenshots, alerting - built so failure is contained, not prevented.

    Java service

    Failures are exceptions you designed for, at boundaries you chose. The compiler removes a whole class of them before deploy; tests remove another. What remains fails loudly, in logs you structured, at the moment it happens.

  4. 4 · Change over time

    Blue Prism

    Fast to build - weeks, not quarters - and the business feels that speed. The cost moves to maintenance: every upstream UI change is your emergency. The bot is coupled to the one thing you don't control.

    Java service

    Slower to first value, because the cost is front-loaded into design. But coupling is to contracts, not screens. When the vendor redesigns their UI, nothing happens. The service ages like infrastructure, not like a workaround.

  5. 5 · The honest answer

    Blue Prism wins when

    The interface is human-only, the timeline is weeks, and the process might not exist in two years. That describes a huge amount of real business - RPA is the right call far more often than backend developers like to admit.

    Java wins when

    You own the systems, the volume matters, and the process must survive years of change. I want to spend my career on this kind of problem - it's why I'm studying and building toward it now.

What RPA work actually taught me

Production RPA is distributed-systems practice wearing a business-process costume. Idempotent steps, because bots get restarted mid-run. Work queues with retry limits and poison-item handling. Audit trails, because finance asks. Defensive waits at every boundary you don't control. None of that knowledge is Blue Prism knowledge - it moves with me to Java intact.

Skills

Tools

The levels are honest. Daily means I work with it every working day. Working means I ship with it. Building means I'm deliberately investing. Past means a previous role, not current practice. Open a tool to see where it's used on this map.

Java where I'm heading

The web what pays now

Ship & host getting it live

Automation where I came from

Not listed: things I've only read about. If it's here, I've built with it.

Background

The long way to Java

Careers read cleaner backwards. This page starts at now and runs downhill into the past - scroll down to go back in time.

  1. Now

    Job search: junior Java Developer, Spring Boot

    Not a whim - a convergence. Automation work taught me how business systems fail. The degree and a Spring Boot bootcamp are putting theory under the practice. Mexveng is where the language gets exercised until it's second nature. The rest of this site is the evidence.

  2. Ongoing

    BSc Computing & IT, Open University

    Part-time, alongside full-time work. Slower than the standard route and better for it: everything lands on problems I've already met in production, so the theory sticks.

  3. Ongoing

    byjoio - freelance web development

    Real clients, real deadlines, real handovers. Freelancing is where I learned that shipping is a skill separate from building - scoping honestly, saying no to features, leaving a client independent rather than dependent.

  4. Ongoing

    RPA developer - business process automation

    Work queues, retries, audit trails, across several roles - Blue Prism earlier, different tooling since. The work that taught me how businesses actually run - and exactly where the limits of automating-from-the-outside sit.

  5. 2016-2017

    Minecraft modding - first Java

    I wanted the game to do something it didn't, and the only path was code. First classes, first NullPointerException, first time changing a system from the inside. A decade later the tools are better and the reason hasn't changed.

That's the bottom. The only way from here is forward - back to now.

Current

Now

Three tracks, running at once, on purpose.

Day
Job hunting - junior Java Developer (Spring Boot) roles, open to backend generally. Currently working in business process automation.
Night
BSc Computing & IT at the Open University, part-time, plus a Spring Boot bootcamp: Hibernate, JPA, REST and MVC, Spring Security, AOP.
Between
byjoio client work, Mexveng - the Java RPG that keeps the language sharp - and ihavenocards.com, free browser card and pub games for when nobody brought a deck.

Also: more hours in games than I'll admit to, and the odd evening producing techno and DnB. All my web work ships pair programmed with Claude Code - my input and review at every stage.

Where this goes

A junior Java backend role - Spring Boot, ideally with people who review code properly. I bring production automation experience, freelance delivery discipline, and a codebase that proves I can sustain a system over time, not just start one.

Contact

One inbox.

Email me or use the form - both land in the same place.

Or use the form

This form is the same pattern every byjoio client site ships - honeypot, native validation, a fallback that works without JavaScript. See how it works.

Code lives at github.com/mjtbase - JPacman and JPong are public. The freelance practice lives at byjoio.co.uk.

The map