7 min read

Modernizing a winter-study roguelike with Vite and ROT.js

Before React reconciled the DOM for you, this game called game.render() after every turn — display.clear(), redraw the map, repeat. That lesson survived a Vite 7 refresh.

  • JavaScript
  • Vite
  • ROT.js
  • Games
  • Rendering

Modernizing a winter-study roguelike with Vite and ROT.js

Before React reconciled the DOM for you, this game called game.render() after every turn. I refreshed the 2025 toolchain without rewriting that lesson — state changes do not paint the screen; you paint the screen.

#TL;DR

Roguelike-Game-Winter-Study-2018 (Weed Strike, Williams Winter Study 2018) is a ROT.js dungeon crawler in plain JavaScript. In 2025 I refreshed the toolchain (Vite 7, ESLint 9, deploy:check) without rewriting the core lesson:

State changes do not paint the screen. You paint the screen.

Play: roguelike-game-winter-study-2018.vercel.app

Winter Study 2018: solo — design, implementation, and tuning were 100% mine.


#Monster AI — pathfinding without melting the frame

PlayMode spawns a mix of entity types mapped to three AI classes:

ClassBehavior
SmartAILonger detection/chase, line-of-sight, stuck detection via position history, shuffled move priorities around walls
BalancedAIMid-range chase; direct move with LOS, else obstacle-aware fallback
SimpleAIShort range; probabilistic chase + diagonal wander

Chase logic uses local heuristics (moveDirectlyToPlayer, moveAroundObstacles, moveSmartRandomly) — not full grid search every frame.

I experimented with ROT.js A* in BaseAI.js. The implementation is still there, commented, with:

JavaScript
// DISABLED: A* pathfinding is too expensive
// Use simple movement instead

Recomputing A* toward the player whenever the player moved made the game feel laggy — the right trade-off for a turn-based ASCII display was cheaper heuristics plus SmartAI’s stuck counter, not optimal paths.

#SmartAI parameters (from SmartAI.js)

ConfigDefaultBehavior
detectionRange19When to start chasing (tuned down from 20)
chaseDistance17How far pursuit continues (down from 30)
Position historylast 5 tilesStuck if ≤ 2 unique cells in history
stuckCounter> 2Switches to shuffled escape moves, then resets

When line-of-sight fails, moveAroundObstaclesSmart() builds a priority list (toward player, perpendicular, orthogonal, diagonal), Fisher–Yates shuffles it, and tries each candidate with moveToPosition. If all fail, it falls back to moveRandomly().

JavaScript
if (uniquePositions.size <= 2 && this.lastPositions.length >= 3) {
  this.stuckCounter++;
} else {
  this.stuckCounter = 0;
}

That is cheaper than A* every turn and reads better in playtests than monsters orbiting a corner forever.


#Corner cases marked in source

The 2025 refresh left inline anchors where a bigger team would use tickets:

LocationNote
js_src/utils/isMobileUI.jsTODO: Sync this with CSS definitions.
js_src/systems/Game.jsTODO: Organize this into a class... (responsive display recreation)
js_src/world/Map.jsDebug paths for entity placement and coordinate sync
js_src/entities/ai/BaseAI.jsDisabled A* block documenting perf cost

#What React taught me to forget

In React 19, you update state (setState, hooks) and the runtime schedules a re-render — a virtual tree diff reaches the DOM (see React docs — render and commit).

In 2018 ROT.js, nothing schedules draws for you:

StepWho owns it
Mutate game state (tryWalk, scheduler, HP)Your entities + DATASTORE
Decide the frame is staleYou (input handler, turn engine, mode switch)
Clear the canvasdisplay.clear() on each Display
Draw tiles, entities, messagesMode render() methods
Show the resultBrowser already has the canvas from prior frames

If you forget game.render() after moving the player, the model updates and the screen lies. That is the same class of bug as stale UI in React — except there is no reconciler to bail you out.


#Three displays, one orchestrator

Game.js mounts three ROT.Display instances:

DisplayPurpose
mainDungeon map + entities (camera-scrolled)
avatarPlayer stats strip (play mode only)
messageLog / status line

Central orchestration:

JavaScript
render: function () {
  this.renderMain();
  this.renderAvatar();
  this.renderMessage();
},

renderMain always clears before delegating:

JavaScript
if (this.curMode && this.display.main.o) {
  this.display.main.o.clear();
  this.curMode.render(this.display.main.o);
}

PlayMode.render clears again at mode scope, then draws the map:

JavaScript
render(display) {
  display.clear();
  const map = DATASTORE.MAPS[this.state.mapId];
  if (map) {
    map.render(display, this.state.cameraX, this.state.cameraY);
  }
  // ...
}

So a frame is: clear → draw terrain → draw entities → draw UI chrome — classic immediate-mode game loop thinking.


#When render() fires (manual contract)

After a successful player step, PlayMode updates the turn clock and explicitly paints:

JavaScript
if (avatar.tryWalk && avatar.tryWalk(movement.dx, movement.dy)) {
  this.moveCameraToAvatar();
  TIME_ENGINE.playerTookTurn();
  this.lastTurnTime = now;
 
  // Render once after complete turn
  this.game.render();
}

Same pattern on death:

JavaScript
Message.clear();
this.game.render();
this.game.switchModes("lose");

Mode switches call this.render() after swapping curMode so startup / win / lose screens appear without a separate subscription system.

Contrast with React: you would store { x, y, hp } in state and trust a child <Dungeon /> to re-run. Here, input code must remember the paint call — that discipline is what I reference in interviews when explaining why React’s batching and concurrent features matter.


#Resize: recreate displays, then render

On window resize, the game recalculates character-cell dimensions (mobile portrait vs landscape vs desktop), removes old DOM nodes, instantiates new Display objects, and calls this.render(). That is closer to remounting a canvas component than tweaking CSS — another place React’s “same component, new props” abstraction hides cost.


#Game systems (still plain JS)

ROT.js handles tile glyphs and input; the repo adds:

  • Scheduler / turn engine — player turn signals monster turns
  • Entity factory — avatar + monster AI variants
  • Mode registry — startup, play, pause, win, lose
  • Persistence — JSON save/restore hooks on Game

The 2025 modernization did not port this to React on purpose. The pedagogical value is the imperative render graph.


#2025 toolchain refresh

The July 2025 refresh (about two days of focused migration work) covered:

ThemeExamples
Vite migrationES modules, Game.init entrypoint, PostCSS, legacy plugin
ArchitectureEntityFactory, DataStore, scheduler, mode registry
UXMobile layout + joystick, message fade, dev:host for LAN testing
CombatMixin fixes for player damaged/attack events
BeforeAfter
Legacy bundlerVite 7 + @vitejs/plugin-legacy
Ad-hoc qualityESLint 9, Prettier 3, deploy:check
Laptop-onlybuild:prod, bundle analyzer, Vercel preview

Scripts worth naming:

  • deploy:check — lint + format check + production build before ship
  • build:analyze — know which polyfills dominate weight
  • README-DEPLOYMENT.md — staging ports, optional Docker, Lighthouse hooks

Overkill for a Winter Study demo — appropriate for practicing release hygiene on throwaway games.


#If I rewrote gameplay in React today

I would not sprinkle useEffect on every keypress. Likely patterns:

ApproachTrade-off
Single useReducer + canvas ref + manual draw() in effectKeeps ROT.js; React only owns schedule
requestAnimationFrame loopContinuous animation; still manual draw
Pure React DOM gridLoses ROT.js glyph performance; different game

The lesson from 2018: separate simulation state from presentation, and make presentation an explicit phase — React just automates that phase for web apps.


#Closing thought

Winter Study code aged when bundlers did; the idea did not. Calling game.render() after every turn is the ancestor of React re-renders — without the safety net. Refresh Vite so the artifact builds; keep the manual render calls so you remember what the framework is doing for you now.


#Reader field guide

When imperative render loops still teach: Canvas or ROT.js games; turn-based simulation where React would hide the paint contract; you want students to feel stale UI when they forget to draw.

Operational checklist

  • Every state mutation on the hot path ends in game.render() (or equivalent orchestrator)
  • display.clear() before each frame at mode scope — double-clear is OK, stale canvas is not
  • Prefer cheap heuristics over A* every turn if pathfinding spikes frame time
  • On resize: recreate Display instances, then render — do not assume CSS rescale is enough
  • Run deploy:check (lint + format + prod build) before shipping static demos
  • Keep AI pathfinding disabled with a comment explaining the perf trade-off

If porting to React later: Single useReducer + canvas ref + explicit draw() beats sprinkling useEffect on every keypress.


#On this site

PostWhy
Building a collaborative editor with CRDTsAnother real-time browser problem—shared state instead of a solo dungeon loop
Building a browser music visualizer with GooserAF loops and teardown discipline without a game grid

#References (curated)

ROT.js owns the grid; React’s render docs explain why remounting Display on resize is a conscious cost, not a free CSS tweak.

ReferenceNotes
ROT.js DisplayCharacter-cell rendering—the DOM backend we kept in the Vite port.
React: Render and commitWhy resize triggers full display rebuilds in the React wrapper.
RepositoryWinter Study 2018 source + Vite modernization branch.
Live demoPlayable build—verify combat and resize on mobile portrait.