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
Eight places, joined where the work is actually joined. Hover a point to light its routes; arrow keys walk them once focused. Anywhere you have been stays lit.
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.
-
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
-
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
-
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();
}
}
-
Movement is somebody else's job
handleInput()reads exactly two numbers offInputHandler, already normalised. Player never touches a key code - swap keyboard for a gamepad or a replay file and onlyInputHandlerchanges. -
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. -
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. -
One number, used twice
speedis set once, in the constructor. Both the move and the possible rollback multiply it by the samedeltaTime- 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.
Play it on itch.io Source on GitHub
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();
}
}
-
Velocity is a vector, not two numbers
position.add(velocity.cpy().scl(deltaTime))is the entire movement model. Thecpy()matters: scaling the velocity in place would shrink the ball's speed to nothing within a second. Copy, scale the copy, add it. -
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.
-
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.
-
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.
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.
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.
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;
}
-
A queue and a trail of breadcrumbs
Two structures do all the work. The queue holds tiles waiting to be explored;
cameFromrecords which tile each one was reached from. The start tile is stored with anullpredecessor - that null is what tells the reconstruction later where to stop. -
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.
-
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.
-
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
cameFrombackwards 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.
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
What leaves · byjoio rebuild
and where they do it.
- 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.
Sent. Or it would be - this is the demo. On a real byjoio site this message replaces the form and the enquiry lands in the client's inbox via their own Formspree account.
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();
}
});
-
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.
-
Let the browser validate
requiredandtype="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. -
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.
-
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 · 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 · 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 · 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 · 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 · 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 direction of travel. Two finished games and an in-progress RPG are the practice ground; the Open University degree is the theory under it; nearly a decade of on-and-off use since Minecraft modding is the foundation.
-
Working through a Spring Boot bootcamp: REST and MVC, both with full CRUD. In progress, not yet shipped in a paid role.
-
Persistence layer for the same bootcamp - object-relational mapping, repositories, the parts that make CRUD honest instead of hand-rolled SQL.
-
Authentication and authorisation on both REST and MVC, plus aspect-oriented programming for the cross-cutting concerns - logging, auditing - that don't belong inside business logic.
-
Rendering, input and the game loop for all three games. Chosen precisely because it does little enough that the architecture stays mine.
The web what pays now
-
Semantic markup, mobile-first layouts on custom properties, BEM-ish naming. Every byjoio build - and this site.
-
Vanilla by default - every interactive element on this site is hand-built, no framework, no build step. View source; that's the point.
-
Functional components, TypeScript where the project already uses it. Reached for when a byjoio project genuinely needs it - which is rarer than the industry suggests.
Ship & host getting it live
-
Version control for everything - every client project, this site included.
-
Hosting for every client site and this portfolio - push to deploy, no server to maintain.
-
The no-backend toolkit for client sites - always set up in the client's own accounts, so handover is real.
Automation where I came from
-
Enterprise RPA in production, two roles back: process design, object layer, work queues, exception handling, production support. I don't use it day to day any more - the engineering habits it taught stayed.
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.
-
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.
-
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.
-
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.
-
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.
-
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.
Sent. It's in my inbox - I'll reply to the address you gave.
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.