← All Posts

No Loitering: Building an Anti-Platformer About the Disappearance of Free Space

Nov 29, 2025

11 min read

AI Policy

Where do you go to just... exist?

Not to buy something. Not to consume content. Not to be monetized by an algorithm optimizing for engagement. Just to be present, maybe meet someone, maybe not. To linger without justification.

I've been thinking about this question for a while. The answer used to be obvious: parks, plazas, libraries, church steps, the town square. Sociologists call these "third places", spaces that aren't home (first place) or work (second place) where community forms organically. You show up, you stay, you talk to strangers or you don't. No transaction required.

Those spaces are disappearing. In the physical world, we're left with commercial venues: bars, restaurants, coffee shops, concerts. Places where presence requires purchase. In the digital world, we have social platforms where algorithms determine who sees what based on engagement metrics and advertising revenue. Even our attention is monetized.

I wanted to build a game about what that feels like.

No Loitering is an anti-platformer where standing still costs money. The floor is slippery by default, friction is a luxury you rent by the second. Linger on any platform too long and an enforcement drone arrives to move you along. The goal is always the same: find a free space to advance. But when you get there, you're placed in an increasingly difficult environment, and the cycle repeats.

Everything costs money. But the more money you have, the fewer barriers exist.

No Loitering Main Screen

Here's how I built it.

Note: This is a technical post about game design and implementation. I'll include code samples and discuss architecture, but the focus is on how mechanics embody the theme, how physics can be social commentary.


The Core Inversion

Traditional platformers give you a problem (gap, enemy, obstacle) and tools to solve it (jump, attack, power-up). The ground is stable. You belong there.

No Loitering inverts this. The floor itself doesn't want you. Every surface has a friction coefficient, and the default is 0.995, meaning you retain 99.5% of your velocity every frame. At 60fps, that's almost no energy loss. You slide. Constantly. The space is designed to keep you moving.

// constants.ts
export const PHYSICS = {
  FRICTION_SLIPPERY: 0.995,  // Ice-like, default state
  FRICTION_ROUGH: 0.85,      // Safe zones only
  FRICTION_RENT: 0.6,        // When you're paying
  // ...
};

The only way to stop is to pay for friction. Hold the rent key (Space) and you apply 0.6 friction. But it costs $0.15 per frame. At 60fps, that's $9 per second. The economy isn't separate from the physics; it is the physics.

This is what it feels like to exist in a space that monetizes presence. You can stay, but you're paying for it. The coffee shop lets you sit, but there's an implicit transaction. The park bench has armrests designed to prevent sleeping. The mall is climate-controlled, but security will ask what you're doing if you're not shopping.

In No Loitering, the question isn't "can I make this jump?" It's "can I afford to stop?"


The Economy as Physics Engine

Most games treat currency as progression. You collect coins, buy upgrades, unlock content. The number goes up, and going up feels good.

In No Loitering, currency is survival and also access. You start with $30. Everything costs money:

Action Cost Notes
Renting friction $0.15/frame ~$9/second at 60fps
Jumping $10 flat Only when grounded
Pausing $1/2sec Yes, thinking costs money
Wall collision 10-50% Varies by difficulty
Falling into void 7-14% Unless Pro tier

The gigs scattered across each level restore currency ($1, $5, $10, $20, or the rare $50 "Golden Parachute"). But they're placed on slippery platforms you can barely control. You have to earn the money to afford the friction to collect the money to afford more friction.

This is a treadmill. That's intentional.

Tier Gating: Wealth as Access

Currency also determines what you can see. The game has three platform types beyond the basics:

  • SLIPPERY (white): Default, always visible
  • PRO (green): Only visible when currency ≥ $50
  • TUNNEL (purple): Only visible when currency ≥ $100, and they blink in and out

This means wealthier players see more paths. The level geometry is identical, but the experience is stratified. If you've dropped below $50, platforms literally disappear. Routes you relied on vanish.

No Loitering Gameplay

This is the core metaphor of the game. The spaces exist. The paths are there. But whether you can access them depends on your economic status. It's the private club, the gated community, the first-class lounge. The infrastructure serves everyone differently based on ability to pay.

I've watched people play this. The moment they realize the green platform they were aiming for has vanished because they spent too much on rent is visceral. They understood immediately what the game was saying, without me having to explain it.


The Physics Architecture

To run at 60fps with real-time collision detection, the game uses a hybrid state architecture. This was a hard-learned lesson.

The Problem with React State

My first attempt stored player position in React state:

// Don't do this
const [playerPos, setPlayerPos] = useState({ x: 100, y: 100 });

useEffect(() => {
  const gameLoop = () => {
    setPlayerPos(prev => ({
      x: prev.x + velocity.x,
      y: prev.y + velocity.y,
    }));
    requestAnimationFrame(gameLoop);
  };
  // ...
}, []);

This triggered 60 re-renders per second. React's reconciliation is fast, but not that fast. The game stuttered. GC pauses became visible.

The Solution: Mutable Refs

The physics engine now mutates refs directly:

// GameCanvas.tsx - the actual approach
const playerPosRef = useRef<Vector>({ x: 0, y: 0 });
const playerVelRef = useRef<Vector>({ x: 0, y: 0 });
const currencyRef = useRef<number>(30);

const gameLoop = useCallback(() => {
  // Direct mutation, no re-render
  playerPosRef.current = PhysicsService.updatePosition(
    playerPosRef.current,
    playerVelRef.current,
    deltaTime
  );

  // Only update React state on significant events
  if (currencyChanged) {
    onCurrencyUpdate(currencyRef.current);
  }

  requestAnimationFrame(gameLoop);
}, [/* stable deps */]);

React state is used for:

  • Game status (MENU → PLAYING → GAME_OVER)
  • Currency display in the HUD
  • Level transitions
  • Scores

Everything else lives in refs. The canvas draws from refs. Physics mutates refs. React only sees what the player needs to see.

Stateless Services

The physics and collision logic is extracted into pure, static services:

// PhysicsService.ts (simplified)
export class PhysicsService {
  static updateVelocity(
    velocity: Vector,
    acceleration: Vector,
    friction: number,
    maxVelocity: number
  ): Vector {
    const newVel = VectorMath.add(velocity, acceleration);
    const dampedVel = VectorMath.scale(newVel, friction);
    return VectorMath.clampMagnitude(dampedVel, maxVelocity);
  }

  static applyGravity(
    velocity: Vector,
    gravity: Vector,
    isActive: boolean
  ): Vector {
    if (!isActive) return velocity;
    return VectorMath.add(velocity, gravity);
  }
}

These are pure functions. No side effects. No internal state. They take the current world, return the next world. This makes them trivially testable. I have 200+ unit tests covering edge cases like zero-velocity bounces and simultaneous multi-platform collisions.


Collision Detection: The Interesting Part

The collision system handles several distinct cases:

  1. Platform landing: Player overlaps a platform from above
  2. Wall collision: Player would pass through a platform edge
  3. Gig collection: Player overlaps a gig's radius
  4. Void detection: Player falls below all platforms
  5. Target arrival: Player reaches the goal zone

Each returns a discriminated union event:

type CollisionEvent =
  | { kind: 'platform'; platform: Platform; surfaceType: SurfaceType }
  | { kind: 'gig'; gig: Gig; gigIndex: number }
  | { kind: 'void' }
  | { kind: 'target' };

This pattern lets the game loop handle collisions exhaustively:

for (const event of collisionResult.events) {
  switch (event.kind) {
    case 'platform':
      currentFriction = getFriction(event.surfaceType);
      break;
    case 'gig':
      collectGig(event.gig);
      break;
    case 'void':
      if (tier < TierLevel.PRO) applyVoidPenalty();
      break;
    case 'target':
      triggerVictory();
      break;
  }
}

The Bounce Problem

Wall bounces were surprisingly tricky. The naive approach, reflect velocity across the collision normal, created a bug where players could clip through corners. The collision would detect one wall, bounce the player, but the new trajectory would intersect an adjacent wall that wasn't checked.

The fix was to check collisions on both axes separately:

// Check X movement first
const xCollision = CollisionService.checkWallCollision(
  currentPos,
  { x: nextPos.x, y: currentPos.y },
  playerRadius,
  'x',
  platforms
);

// Then check Y movement (using potentially bounced X)
const yCollision = CollisionService.checkWallCollision(
  { x: xCollision.wouldCollide ? currentPos.x : nextPos.x, y: currentPos.y },
  nextPos,
  playerRadius,
  'y',
  platforms
);

This ensures each axis is resolved before checking the next. No more corner clips.


The Loitering System

The game's title comes from its most direct commentary. Spend too long on one platform (20 seconds) and an "Enforcer" drone spawns.

// Loiter detection
const timeOnPlatform = now - platformEntryTime;
if (timeOnPlatform > LOITER_LIMIT && !enforcerActive) {
  spawnEnforcer(currentPlatform);
}

This is the "No Loitering" sign. The bench with armrests in the middle. The anti-homeless spikes. The security guard asking if you need help finding something. Presence without purchase is suspicious. Staying still is a violation.

The Enforcer is a simple AI: it accelerates toward the platform where you loitered. It's not fast, but it's persistent. It will chase you across the level until you either:

  1. Leave the level (complete it)
  2. Rent the compromised platform ($5 to "own" it)
  3. Die

Option 2 is the key, but it's more limited than it sounds. Renting a platform only protects you on that platform. Jump to another surface and you're back on the clock. Linger too long on the new platform and the Enforcer returns. You've bought a safe spot, not safety.

And if you die? All your rented platforms are gone. The money you spent is still spent. You restart with nothing but whatever currency carried over from your last attempt. Your investments in space don't survive failure.

This creates interesting decisions. Sometimes it's cheaper to just keep moving. Other times, buying a foothold near the exit saves you. But you're always one mistake away from losing everything you've paid for, which I think is a pretty accurate simulation of navigating modern urban space on a precarious income.


Procedural Music

The soundtrack is generated algorithmically using the Web Audio API. No audio files.

// MusicPlayer.tsx (simplified)
const playNote = (frequency: number, duration: number, startTime: number) => {
  const oscillator = audioContext.createOscillator();
  const gainNode = audioContext.createGain();

  oscillator.type = 'sawtooth';  // Cyberpunk aesthetic
  oscillator.frequency.value = frequency;

  gainNode.gain.setValueAtTime(0.3, startTime);
  gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);

  oscillator.connect(gainNode);
  gainNode.connect(audioContext.destination);

  oscillator.start(startTime);
  oscillator.stop(startTime + duration);
};

The music uses a scheduling pattern: notes are queued ~100ms ahead using AudioContext.currentTime. This prevents timing drift and allows smooth transitions between game states.

Three tracks exist:

  • Menu: Ambient, low bass, minimal movement
  • Playing: Walking bass, rhythmic tension, rising progressions
  • Game Over: Descending melody, resolution

All in D-minor. It felt right.


Level Design Philosophy

The game has 15 levels plus 8 tutorial modules. Each level is hand-designed to teach and test specific mechanics:

Level Name Focus
1 Home Sweet Home Basic sliding, first gigs
5 Archipelago State Jump mechanics, islands
7 Red Tape District Hostile walls, damage
9 Invisible Hand Tier-gated platforms
11 Kinetic Battery Bouncer walls into void
15 The Boardroom Everything combined

The naming is intentional. "Red Tape District." "Invisible Hand." "Hostile Takeover." These are spaces you're not supposed to be in. They are corporate, bureaucratic and hostile by design.

But here's the cycle that makes the game feel like the thing it's commenting on: you complete a level by reaching a "free space" (the target area). Victory. Relief. You made it somewhere you could exist without paying.

Then you're placed in the next level. Harder. More hostile walls. Fewer tier-gated platforms. The free space you found was just a waypoint to a more difficult environment.

This is the gentrification treadmill. The affordable neighborhood gets discovered, prices rise, you move further out. The free park gets redeveloped into luxury condos with a "public plaza" that has security. You find a new place. It gets harder to stay. The game doesn't end when you reach safety, it relocates you to somewhere less safe.

Gig Distribution

Gigs are generated algorithmically based on level ID:

// Early levels: many small gigs
// levels.ts
if (levelId <= 7) {
  return generateGigs(platforms, { $1: 20, $5: 5 });
}
// Late levels: fewer, larger gigs
if (levelId >= 12) {
  return generateGigs(platforms, { $20: 3 });
}
// Final level: one massive payout
if (levelId === 15) {
  return generateGigs(platforms, { $50: 1 });
}

This creates a progression arc: early levels train precision with many collection opportunities; late levels reward risk-taking with single high-value targets placed in dangerous locations.


What I Learned

Building No Loitering taught me several things:

1. Mechanics are metaphors, whether you intend them to be or not. The friction system wasn't designed to be "about" anything at first. It was just a physics experiment, what if the floor was always slippery? But once I added the economy, the metaphor crystallized. Every design decision mapped onto something real. Friction as the cost of public space. Rent as access rights. Wealth as literal visibility. The tier system as economic stratification. The game says things I never explicitly wrote.

2. Games can make arguments that essays can't. I can write about how public space is disappearing, how commercial interests colonize every square foot, how algorithms extract value from attention. But those are words. The moment a player realizes they've been priced out of a platform they were standing on, that's felt. The frustration is real. The calculus of whether to rent or keep moving is visceral. Games can make you experience a system, not just understand it.

3. Performance constraints shape architecture. The hybrid React/ref approach wasn't in my original design. I discovered it by hitting 15fps and working backward. The resulting pattern (React for UI, refs for physics) is cleaner than what I would have designed upfront.

4. Stateless services are testable services. Having 200+ tests isn't impressive by itself. What's impressive is that I can refactor the collision system without fear because I know exactly what behavior I'm preserving. Pure functions made this possible.

5. Sound design creates place. Adding procedural music changed the game's feel more than any physics tweak. The low sawtooth bass makes the dystopia real. Players feel surveilled before the Enforcer even spawns. The space feels hostile before the mechanics confirm it.


Why React? (Or: The Learning Project)

A reasonable question: why build a real-time physics game in React?

The honest answer is that this was never meant to be a product. It's a learning project. I wanted to understand frontend UI development better. React's component model, state management patterns, the lifecycle of a browser application, I've worked in Vue.js (years and years and YEARS ago) as well as old-school jQuery (I'm old). Building a game seemed more interesting than building another todo app.

The result is educational in ways I didn't expect. Hitting 15fps taught me more about React's reconciliation than any tutorial. Debugging collision detection taught me about separation of concerns. The refactoring I'm doing now (extracting stateless services, applying SOLID principles to make components more modular) is making me a better frontend developer in ways that transfer directly to "real" applications.

This project has also been a collaboration. I've used Claude Code as a development partner throughout, bootstrapping the initial structure, advising on patterns I wasn't familiar with, and serving as a brainstorming partner when I got stuck. It's a different way of learning: instead of reading documentation and hoping I understood it correctly, I can ask questions, get explanations in context, and iterate on ideas in real-time. The AI doesn't replace understanding, but it accelerates getting there.

The game is buggy. It's a work in progress. The physics has edge cases I haven't handled. Some levels are more frustrating than challenging. The code has sections I wrote at 2am that I'm not proud of.

Eventually, I'll probably port this to a proper game engine like Godot. React and Canvas were never the right tools for a physics platformer. But that's okay. The point was to learn by building something I actually wanted to play, and I've done that. The game exists. It runs. It says something. The rest is iteration.


The Uncomfortable Part

I'll be honest: I don't know if this game is fun.

It's compelling. Playtesters keep playing. They curse at the screen. They restart levels. They develop strategies (hoard money early, spend late; rent the platform next to the goal).

But "fun" implies leisure, and No Loitering is work. It simulates a specific kind of stress: the stress of existing in spaces designed to extract value from your presence. You're always sliding. You're always spending. The game doesn't want you to win; it wants you to keep moving.

Maybe that's the point. The game recreates a feeling that many people experience daily: the low-grade anxiety of being somewhere you're not sure you're allowed to stay. The calculation of whether you can afford to sit down. The awareness that someone might ask you to leave.

Is that fun? I don't know. But it might be meaningful. And maybe "fun" isn't the right metric for a game about the disappearance of spaces where fun can happen freely.


Play It

The game runs in any modern browser. Clone the repo, run npm run dev, and slide. NOTE: The game is currently in development and may not work as expected. There are plenty of bugs and errors right now that I'm working on fixing.

git clone https://github.com/sysn3rd/NoLoitering.git
cd NoLoitering
npm install
npm run dev

Controls:

  • WASD/Arrows: Move
  • Space: Rent friction (costs money)
  • Enter: Jump (costs $10)
  • Tab: Rent current platform ($5)
  • Shift+Cmd+D: Debug menu (on main screen)

The first level is forgiving. The last level is not.


Additional Resources

Third Places & Public Space

Game Physics & Architecture

Procedural Audio

Game Design & Social Commentary

  • MDA Framework - Mechanics, Dynamics, Aesthetics paper
  • Papers, Please - Another game that makes you feel systemic oppression through mechanics
Ryan Davis

Written by

Ryan Davis

Systems thinker and accessibility advocate building AI/ML solutions with a focus on agentic workflows. When not coding for non-profits or tinkering with robotics, I geek out over distributed systems design and making technology work for everyone.