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.

The Placebound start screen with buttons for a local game, the lessons, the rulebook, the compendium and an online game
The start screen

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.

The Placebound board at the start of a match, the Tide army in ivory and teal on the left and the Ember army in charcoal and orange on the right
A new match. Every figure faces the enemy's home columns

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.

The challenge screen for a Tide Knight attacking an Ember Goblin on D3, with both health values and the lava vent hazard
Before a duel, the challenge screen shows both pieces, the arena and its hazard. The Goblin gets 40 temporary health because D3 is at full dark
A Knight and a Goblin in an open arena while a lava vent erupts under the Goblin
Knight against Goblin on D3. A lava vent erupts under the Goblin

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.

All 20 kinds for both sides on one board, from the Knight to the four elementals
All 40 figures in the lineup scenario, a development position that puts every kind for both sides on shuffled squares

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 compendium page for the Dragon, with the figure on a turntable in Ember colours, its numbers against the roster, and its strengths and weaknesses
The compendium. The Dragon in Ember colours, with its numbers against the rest of the roster

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.