The Light Moved on Its Own: How Project Ghost Fixed Its Photon Fusion Flashlight

In Project Ghost, the owning client saw its flashlight correctly, but the host replicated it wrongly: the host recalculated the light from its own camera instead of forwarding the client position. The fix is to apply inputs locally, send the finished position, and never recalculate it on the host.

The Light Moved on Its Own: How Project Ghost Fixed Its Photon Fusion Flashlight
Guide chapters9 chapters

Your flashlight points exactly where you aim it, on your screen. On everyone else's screen, it points somewhere else. That was the state of Project Ghost for about a year, and the bug survived because the player who owned the light never saw anything wrong, while the wrong picture only appeared on someone else's screen.

Self-made title card, not a game screenshot or test capture.
  • Symptom: on the owning client the flashlight looked right, but the host replicated it wrongly to the other clients.

  • Trigger: the bug only became obvious after light collision detection was added, when shining a light on an interactable did nothing.

  • Clue: with the client standing still, the client's replicated light on the host screen moved whenever the host moved.

  • Root cause: the host recalculated the light position from its own camera instead of using only the client's position.

  • Fix: apply the client's inputs locally, pass the resulting position to the host, and do not recalculate it there.

  • Code warning: the published fix is an image only, and its visible snippet has two defects, so this article does not present it as copy-ready code.

The game and the setup

Project Ghost is Caldera Interactive's multiplayer cooperative adventure roguelite, with up to four players exploring and looting dark, haunted mansions using only their flashlights. Lighting is not decoration here. It is the core mechanic, which is why a wrong flashlight direction is not a cosmetic glitch: it decides what a player can see, aim at, and interact with.

The networking model, as the team describes it, is simple on purpose. When players spawn in, one player is selected as the host, and the host holds state authority, meaning it owns the state of the world. Other players, called clients, connect to the host and receive updates about the world through it. Things that need instant feedback, such as movement and the direction of a flashlight, are applied locally first and then propagated to the state authority. That last sentence is the whole design contract, and the bug is a violation of it.

Self-made explanatory card, not a game screenshot or test capture.

The symptom that finally showed

After about a year of development, the team noticed that the direction a flashlight pointed was not being replicated across clients properly. The client saw its own flashlight in the right place, but the host replicated it incorrectly across the other clients. The mismatch became obvious only when the team implemented light collision detection: a client could see itself shining its light on an interactable, yet nothing happened, because the state authority had not placed the light in the same place.

At first the team suspected something small, perhaps a rounding error or a loss of precision in the decimals being passed over the network. That guess is worth recording, because it is the most natural first guess for a replication mismatch, and it was wrong. The real defect was not precision. It was provenance: whose camera the position had been computed from.

Self-made explanatory card, not a game screenshot or test capture.

The clue

After many hours of troubleshooting, the team found the experiment that settled it. If the client stood still without moving its light, and the host moved around, the replicated light of the client on the host screen moved as well. A position that belongs to a stationary player cannot legitimately move when a different player walks around. The only consistent explanation was that the replicated light was somehow factoring in the host's position.

Self-made explanatory card, not a game screenshot or test capture.

The root cause

The team's own restatement of the bug is precise, and this article keeps it intact. Clients were updating their light position locally, but when the position was sent to the host, the host recalculated the light's position based on its own camera instead of only using the client's. Two players have different camera positions, so the recalculation yielded a different position. The client never saw the problem, because on the client's own screen the local value was right. Only the host screen, and every client downstream of the host's version, showed the wrong light.

The team also says where the mistake came from. They got it wrong when transferring their codebase from PUN, Photon's previous networking package, to Fusion. They add two contributing factors in their own words: poor documentation from Photon Fusion at the time, and a new architecture with a small support community. This article reports those as the team's own account, not as an independent verdict on Photon documentation today.

Self-made explanatory card, not a game screenshot or test capture.

The fix, in plain steps

  • Step 1: immediately apply the client's inputs to the client's own game, so local feedback stays correct.

  • Step 2: pass the resulting position to the host, as a finished value, not as raw material for another computation.

  • Step 3: make sure the position is not recalculated on the host, so it replicates to all other clients without factoring in the host's position.

  • Reference used by the team: Fusion documentation, especially the Tanknarok example project, consulted in time for the end of the prototyping phase.

Self-made explanatory card, not a game screenshot or test capture.

Reading the published code snippet carefully

The fix code was published only as a picture, not as text, so this article does not reproduce it as a code block. Reading the picture line by line, the structure is this. A networked Vector2 called flashlight_direction is declared. In FixedUpdateNetwork, if input is obtained through GetInput with a NetworkInputData value, the snippet's own comment says the host receives the input and assigns the networked variable to pass it to everyone, and the code assigns the normalized aimDirection from the input to flashlight_direction. Then, if that direction has a non-zero squared magnitude, the snippet's own comment says every client calls this and points the light in the right direction, and the code fetches a character light and lerps its forward vector toward a new Vector3 built from a two-component direction, with a factor of Time.deltaTime multiplied by 100.

That transcription was checked line by line, and it found two defects in the published picture, so the snippet must not be copied verbatim. First, the aim line builds its Vector3 from a variable called dir, but dir is never declared anywhere in the visible snippet; the only direction variable in scope is flashlight_direction. A reader implementing this must decide, in their own code, which value supplies that direction, and this article does not silently rewrite the snippet to pretend the question does not exist. Second, the assignment line that stores the normalized aimDirection has no trailing semicolon in the picture, which as printed would not compile. The helper that fetches the character light is also not defined in the visible snippet, so it is an excerpt, not a complete file. Separately, whether Time.deltaTime inside FixedUpdateNetwork matches the author's own project tick setup is not shown by the snippet and is recorded here as unverified, not as a defect. The design rule in the previous section is verified against the team's own prose; the code picture itself is not verified as runnable.

Self-made explanatory card, not a game screenshot or test capture.

Why it hid for a year

Three properties conspired to hide this bug. First, before light collision detection existed, the discrepancies were subtle, so nothing forced anyone to compare exact positions. Second, the behaviour is most noticeable the further the host is from the client, but past a certain distance the client's light leaves the camera view on the host screen, which makes it hard to diagnose: the case where the error is largest is also the case where it is hardest to look at. Third, the client does not see the issue on its own screen at all. A bug that is invisible to the player who owns the broken object, and most visible exactly when it is hardest to observe, can survive a long time without anyone feeling certain enough to chase it.

Self-made explanatory card, not a game screenshot or test capture.

What the team changed about testing

The team drew three testing lessons, and all three are about making the invisible visible. The lighting system was the first thing they implemented almost a year earlier, and they had learned a lot since, so the initial implementation carried false assumptions. From that, they took ownership of testing features as a team: all testers now stream their game and look at each other's stream to catch subtle issues while testing. And they added a step to their testing template: cross-reference the state authority (host) and the input authority (client) at different distances, not just at one comfortable range.

  • Compare the host view against the client view for the same object, in the same moment, instead of trusting either view alone.

  • Vary the distance between host and client deliberately, because this class of bug scales with distance.

  • Treat a feature that only looks right on the owner's screen as untested until the receiving side has confirmed it.

Self-made explanatory card, not a game screenshot or test capture.

Take this into your own project

  • Ask, for every replicated value, who owns the input and who owns the state, and write the answer down before the code spreads.

  • Decide, for every hop, whether the receiver forwards a finished value or recomputes one, and never let a recompute hide inside a forward.

  • Test the far-apart case on purpose, not only the close-up case that looks fine in a demo room.

  • When a published snippet is only a picture, transcribe it and check it line by line before anyone copies it, including the names that are used but never declared.

Sources: Caldera Interactive, Our Game Was Broken For a Year, published 2023-05-01, calderainteractive.com/blog/our-game-was-broken-for-a-year. All bug, cause, fix, and testing claims in this article come from that post; the line-by-line code reading and the two defects are this article's own check of the post's code picture, stated as such. No new benchmark, frame rate, code, or test result has been invented for this article. This article contains no affiliate links, and no price is stated.

  • One String Length, Recalculated 63,000 Times: How t0st Cut GTA Online Loading by 70 Percent

  • One Missing Glyph Cost 16 MB: How Foxy Dumplings Cut Font Atlas Memory From 48 MB to 4-8 MB

  • Profile First, Then Refactor: How a Unity RTS Developer Reports Tripling FPS in a Month

Save this guide

Sign in to save guides and sync them across devices.

Sign in