I played Archon a lot on the C64 as a teenager. Two players, Light and Dark, move pieces on a board that looks like chess. When a piece moves onto an enemy square, the board disappears and the two pieces fight in real time in an arena. Win the fight and you take the square. Lose it and your piece is gone.
I always liked that mix. The board decides where the fights happen, and the fights decide who keeps the board.
So I built my own version for the browser. It’s called Placebound, and you can play it at placebound.dariuszparys.com. It’s a desktop game for two players: at one desk, in two browsers over the internet, or with game controllers. If you’re alone, the lessons teach the game against a practice opponent.

What Placebound is
The 9×9 board, the light and dark squares, the movement types and the seven spells follow the original Archon: The Light and the Dark. The names, the figures, the arenas, the hazards and all the combat numbers are mine. The two sides are called Tide (Light) and Ember (Dark). Each side starts with 18 pieces:
| Tide / Light | Move | Ember / Dark | Move |
|---|---|---|---|
| Knight ×7 | Ground 3 | Goblin ×7 | Ground 3 |
| Archer ×2 | Ground 3 | Manticore ×2 | Ground 3 |
| Unicorn ×2 | Ground 4 | Basilisk ×2 | Ground 3 |
| Golem ×2 | Ground 3 | Troll ×2 | Ground 3 |
| Valkyrie ×2 | Fly 3 | Banshee ×2 | Fly 3 |
| Djinni | Fly 4 | Dragon | Fly 4 |
| Phoenix | Fly 5 | Shapeshifter | Fly 5 |
| Wizard | Teleport 3 | Sorceress | Teleport 3 |
The Unicorn moving one square further than the Basilisk is from the original. So is the Shapeshifter, which copies whichever piece it fights.
You win by holding all five power points (A5, E1, E5, E9 and I5) at the same time, by eliminating the other side, or by imprisoning its last piece. If both sides lose their last pieces in the same duel, the match is a draw, and so is a match where 100 actions in a row change nothing on the board. Online, a player who stays disconnected for 30 seconds forfeits the match.
Some squares are always light, some always dark, and the rest follow a six-stage luminosity cycle. A piece fighting on a square that favors its side gets temporary health for that duel: 8 points per luminosity stage, up to 40 on a fixed square of its own color. Between rounds every piece except the Shapeshifter recovers 2 health, or 6 on a power point. The two commanders, the Wizard and the Sorceress, can cast each of seven spells once per match: Teleport, Heal, Shift Time, Exchange, Summon Elemental, Revive and Imprison. Casting replaces the move, and every cast costs the commander 5 points of maximum health for the rest of the match.

Placebound adds these on top of the original:
- Every duel happens inside the arena of the challenged square. There are seven layouts, from a 9×9 pillar hall to a 16×8 canyon, and they don’t change with the luminosity cycle like the barriers in the original.
- Eight squares have a hazard. On D3, F3, D7 and F7 a lava vent opens under one fighter every 1.6 seconds, the attacker first and then alternating. On the outer power points A5, E1, E9 and I5 a Sentinel turret locks onto the nearer fighter and fires a bolt that can hit either of them.
- Online play in two browsers. Anyone else who opens the room link can watch.
- Seven short lessons that teach the game inside the real interface, plus an illustrated rulebook with the complete rules.
- Rumble when your fighter gets hit. The original was played with joysticks, and in Placebound a controller works for the whole game as well.
The board also turns, tilts and zooms with the mouse, the keyboard or the right stick, and a compendium shows every figure on a turntable with what it does, its numbers against the rest of the roster, and three strengths and weaknesses.


How I built it
I built Placebound with Claude Code. I decided what the game should be and what was wrong with it, and Claude Code wrote the code.
Each big step started with a written plan in docs/plans/: the classic rules,
the arenas with their hazards, the game HUD with the tutorial, the Blender
figures, and the board view with the compendium. I described the problem,
Claude Code wrote the plan with what the step has to deliver, which rules it
must follow and how to verify it, and then implemented it. The HUD plan came from two players who told me the game
had no onboarding and that the interface looked like a CRM, with a tiny board
and tiny buttons. The plan wanted the board to cover 80% of the window. That
would have put the side panels over the edge squares, so I chose to keep the
panels beside the board and measure the board against the space between them.
Verification ran in Chrome through the Chrome DevTools MCP server: scripted key events, prepared positions, two browser contexts for online matches, and stand-in controllers for the controller work. Each round of checks is written down in the repository with what was expected, what happened, and what wasn’t covered.
For the controller work and the rumble I also had review agents go over each diff from different angles. The controller review found 14 defects, among them menu input that steered an online fighter and a lone controller in the second slot doing nothing in lessons. The rumble review found three. Online kills never triggered the fatal rumble, because the server skipped the last snapshot of a duel. And the Banshee’s scream deals a little damage on every simulation step, which never added up to a rumble. All of them are fixed and have regression tests.
The figures
The figures started as Three.js primitives assembled in code: a cylinder body, a sphere head, a cone for the plume. When I set up the Blender MCP server and asked Claude Code to model the Knight, it rebuilt that Knight in Blender from the game’s own code, and seeing it there made clear how abstract all 20 figures were. A first redesign stayed in code. Then I asked for the real thing: every figure modelled in Blender, loaded by the game as a model, recognisable from the board camera, and as close to a professional game model as the process allows.
Nobody sculpted in Blender’s interface. Each of the 20 kinds has a Python
script that builds it in a background Blender on a shared contract: Blender
units are the game’s units, a height range per tier so a commander always
stands tallest, a footprint, a budget of 18,000 triangles, and six material
roles named body, cloth, skin, trim, metal and eye. The game colours each role
with the owning side’s palette, so one model serves Tide, Ember and a
Shapeshifter’s copy. Ambient occlusion and edge highlights are baked into the
vertex colours, because there are no texture maps, no rigs and no animation.
The build writes a .blend file to open and inspect and a Meshopt-compressed
GLB that the game loads. The scripts came from a multi-agent workflow with an
art director that reviewed every figure in three rounds against an AAA bar. It
approved only the Golem and the Banshee. The Water elemental scored 5 of 10
after four rounds of redesign, and I accepted it when I saw it on the board.
Those scripts are about 19,500 lines of Python, more than the game itself, and
almost all of them written by agents.

A blind test checked whether the figures read from the board camera: twelve judges named 239 of 240 pieces on four boards from the roster alone. The judges were AI models choosing from a closed list of 20 names, which is easier than a player naming a piece unprompted. My own decision cost some of that. I wanted every figure to face the enemy’s home columns instead of the camera, and side-on the Dark pieces read worse: 44 of 60 instead of 60 of 60 before, mostly the Shapeshifter, the Banshee and the Troll. I kept the facing. The game also found a bug the Blender previews never showed: on the board, cloth and skin stayed at model size inside the plinth, so Dark pieces showed flat orange shapes where their robes should be.

The models add about 3 MB of compressed downloads before the first frame, behind a loading screen. The primitives needed none of that. The compendium text was written by agents too, four writers and three adversarial reviewers who reported 54 problems, and an editor checked each claim against the code.
The result is about 12,000 lines of TypeScript. Roughly 8,400 are the game, 3,400 are 158 tests, and 180 are the Cloudflare Worker. The rules, the combat and the online room have unit tests. The two largest files, the UI wiring and the Three.js view, are covered only by the browser checks. The repository is private, so the excerpts in this post are the code you get to see.
The stack
There’s no game engine beyond Three.js for rendering. Vite bundles the TypeScript, Three.js draws the board and the arenas and loads the 20 figure models with its own glTF loader and Meshopt decoder, and the fonts (Cinzel and Inter) come from Fontsource. Those three packages are the only runtime dependencies.
The rules are plain TypeScript without any rendering. movement.ts,
cycle.ts, health.ts and spells.ts answer questions about a match, and
rules.ts applies an action. The real-time fight lives in a Combat class that
advances in fixed steps of 1/120 of a second. It doesn’t know about Three.js,
the DOM or the network. The browser draws whatever state it holds.
Because of that separation, the tests run in Node with its built-in test
runner through tsx, without a browser, and the same Combat class runs in
the browser for local games and on the server for online games.
Online play on Cloudflare
The whole game, static files and online rooms, is deployed as one Cloudflare
Worker on the Workers Free plan. This is its wrangler.jsonc without the
comments:
{ "name": "placebound", "main": "worker/index.ts", "compatibility_date": "2026-09-01", "routes": [ { "pattern": "placebound.dariuszparys.com", "custom_domain": true } ], "workers_dev": false, "preview_urls": false, "assets": { "directory": "./dist", "binding": "ASSETS", "run_worker_first": ["/ws/*"] }, "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "RoomObject" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RoomObject"] }]}These are the Cloudflare pieces it uses:
| Component | What it does in Placebound |
|---|---|
| Workers | Routes /ws/<room> to the room, everything else to the static files |
| Workers Static Assets | Serves the Vite build from dist/ |
| Durable Objects | One object per room code, runs the match and the duels |
| Durable Object storage | Keeps the room state in the object’s SQLite storage |
| WebSocket Hibernation API | Keeps sockets open while the object isn’t running |
| Durable Object alarms | Ends a match 30 seconds after a player disconnects, deletes a room after 24 hours |
| Custom Domains | placebound.dariuszparys.com, with the DNS record and certificate managed by Cloudflare |
One Durable Object per room
run_worker_first: ["/ws/*"] means Cloudflare serves the HTML, JavaScript and
fonts directly. The Worker code only runs for room sockets and for requests the
asset server has no file for:
export default { async fetch(request, env): Promise<Response> { const url = new URL(request.url); const route = url.pathname.match(/^\/ws\/([^/]+)$/); if (!route) return env.ASSETS.fetch(request); const code = route[1].toUpperCase(); if (!isRoomCode(code)) return new Response("Unknown room", { status: 404 }); if (request.headers.get("Upgrade") !== "websocket") return new Response("Expected a WebSocket upgrade", { status: 426 }); return env.ROOMS.get(env.ROOMS.idFromName(code)).fetch(request); },} satisfies ExportedHandler<Env>;When you click Create online game, the browser picks a six-character room
code and opens ?room=CODE. idFromName(code) maps that code to the same
Durable Object for everyone who opens the link, and Cloudflare creates the
object on first use. Every message for a room arrives at that one object, so it
decides the order of events without any locking.
The room code is the only secret. Anyone with the link can take an empty seat or watch, which is what you want for a game you send to a friend. The room checks the shape of every message before it acts on it, and the Worker drops messages over 4 KB.
The room is authoritative. On the board, a browser sends a command, such as moving a piece or casting a spell, and the room checks it with the same rules code before it applies it. In a duel, a browser only sends its controls: a direction and whether the attack button is down. The room runs the fight in steps of 1/120 of a second and sends a snapshot about 30 times a second. The browser draws the duel 60 milliseconds in the past and interpolates between two snapshots, so the fighters move smoothly instead of jumping with every packet. The hazards are simulated only on the server. The browsers just draw them.
The price of that is input latency. There is no client-side prediction, so your own fighter reacts one round trip after you press a key. I chose the simpler server-only model, and whether the delay matters in a close fight is something only more matches will show.
This is the shape of an online duel, leaving out the lobby messages, the spectators and the error cases:
sequenceDiagram
participant T as Browser (Tide)
participant R as Room Durable Object
participant E as Browser (Ember)
T->>R: WebSocket /ws/CODE, claim seat, ready
E->>R: WebSocket /ws/CODE, claim seat, ready
R-->>T: started
R-->>E: started
T->>R: command (attack a square)
R-->>T: encounter
R-->>E: encounter
loop duel
T->>R: controls
E->>R: controls
R-->>T: snapshot (~30 per second)
R-->>E: snapshot (~30 per second)
end
R-->>T: final snapshot, settled
R-->>E: final snapshot, settled
The Room class that holds the game logic doesn’t know it runs in a Durable
Object. It gets two callbacks, send and save. In production the Durable
Object provides them, and in the tests plain functions do. That’s how the
online tests run rooms in Node.
Hibernation and cleanup
The room accepts its sockets through the WebSocket Hibernation API. Between moves, while a player thinks, nothing runs, and Cloudflare can evict the object from memory while the sockets stay open. A 60 Hz timer runs only during the duel countdown and the fight, and only while both fighters are connected. That second condition was missing at first. A pending timer keeps a Durable Object resident and billed, so a duel paused by a disconnect would have kept the object awake until the room was deleted a day later. At 128 MB that is about 10,800 GB-seconds, and the Free plan allows 13,000 per day. Two abandoned duels on the same day would have blocked online play for the rest of the day. Now the timer stops when a fighter drops out, and the object’s alarm takes over the timing.
When the object starts again, its constructor loads the room state from storage and re-attaches the open sockets. If the object restarts in the middle of a duel, the duel starts over from the saved encounter with a new countdown.
When the last connection closes, players and spectators alike, the room notes the time and sets its alarm 24 hours ahead. If anyone reconnects, the alarm is cancelled. If nobody does, the alarm deletes the room’s storage. A Durable Object has one alarm, so the same alarm also serves the 30-second forfeit clock described below, and the Worker always arms it for whichever deadline comes first.
Seats and reconnects
Each browser creates a random token per room and keeps it in localStorage.
The token is the seat. A reload or a dropped connection reconnects with the same
token, and the player gets the same side back. The client retries with a
growing delay, capped at five seconds.
While a seated player is disconnected during a match, the game pauses and the opponent sees a 30-second countdown with a Claim victory button. If the player returns in time, the match continues where it stopped. If not, or if the opponent claims the win, the match ends by abandonment. If both players stay away for 30 seconds, it’s a draw. The clock runs on the object’s alarm, so it works even when the object has been evicted from memory in the meantime.
Domain and cost
The routes entry with custom_domain: true makes Cloudflare create the DNS
record and the certificate for placebound.dariuszparys.com on deploy.
workers_dev and preview_urls are off, so that’s the only address the game
answers on. Deploying is npm run deploy, which builds and runs
wrangler deploy.
Everything runs on the Workers Free plan. The room class is declared with
new_sqlite_classes, so it uses the SQLite storage backend, which is the only
Durable Object backend the Free plan supports. The custom domain on a zone I
already had on Cloudflare didn’t need a paid plan either.
Controllers and rumble
The game accepts any controller the browser reports with the standard
button mapping, which is what current browsers report for common Xbox and
PlayStation pads. In a local match, the first controller plays Tide and the
second plays Ember. Outside duels, the controller moves the keyboard focus
around the board and the menus, and pressing A clicks the focused button. The
right stick turns and tilts the board, the triggers zoom it, and LB and RB page
through the compendium. Every press goes through the same handlers as a mouse
click, so there are no separate rule checks for controllers. The fighter
plates and the rulebook show the button names of the connected controller, so
a PlayStation player sees ✕ instead of A.
I’ve played it with a DualSense, and it works. The earlier checks used
stand-in controllers, Xbox and DualSense identifiers injected into
navigator.getGamepads(), so a physical Xbox pad hasn’t been tested yet.
When your fighter gets hit, your controller rumbles through the Gamepad API’s
vibrationActuator.playEffect("dual-rumble", ...). Heavier hits rumble harder
and longer:
export function hitRumble(damage: number, fatal: boolean): Rumble { if (fatal) return { duration: 420, strongMagnitude: 1, weakMagnitude: 0.8 }; const strength = Math.min(1, 0.35 + damage / 60); return { duration: Math.round(Math.min(260, Math.max(100, 80 + damage * 4))), strongMagnitude: strength, weakMagnitude: strength * 0.7, };}A Knight’s strike gives about 0.13 seconds, a Dragon’s about 0.26, and a fatal
blow 0.42. The Banshee’s scream and the Phoenix’s fire hurt a little on every
step, so that damage is added up and reported at most every 0.1 seconds. The
result is a steady buzz at any frame rate. The stand-in controllers recorded
every playEffect call with the expected durations and strengths, and on the
DualSense the rumble felt right.
Firefox and Safari don’t count a controller press as user activation, so they may refuse Copy link when you press it with a controller. The game then selects the link in the text field so you can copy it yourself.
What’s missing
- The combat numbers are untuned defaults. There has been no competitive human playtest, so nobody knows whether Tide or Ember is stronger.
- There is no computer opponent, although the original had one. The lessons have practice opponents, but a full match needs a second person, next to you or online.
- Phones and tablets aren’t supported. The game needs a desktop browser with WebGL2, and there are no touch controls.
- There is no client-side prediction, so your own fighter moves one round trip late in online duels.
Try it
Open placebound.dariuszparys.com in a desktop browser. If you’re alone or never played Archon, click Learn to play: the first lesson takes about two minutes and ends in a real duel. If you only want to look at the figures, open the Compendium. When you have an opponent, click Create online game and send them the link, or sit at one desk. And if you played Archon on the C64 too, I’d like to know how close it feels.
